Compare commits
82 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 | |||
| 2db565ff9c |
+158
@@ -4,6 +4,164 @@ 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
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
# seismo-relay `v0.12.0`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing MiniMate Plus seismographs.
|
||||
|
||||
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
||||
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
||||
|
||||
> **Status:** Active development. Full read + write + erase + monitoring
|
||||
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
||||
> handles inbound unit connections, downloads events, and persists everything
|
||||
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||
|
||||
---
|
||||
|
||||
## What's in here
|
||||
|
||||
```
|
||||
seismo-relay/
|
||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||
│
|
||||
├── minimateplus/ ← MiniMate Plus client library
|
||||
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
||||
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
||||
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
||||
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
||||
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
||||
│
|
||||
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
||||
│ ├── server.py ← All device + DB endpoints
|
||||
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
||||
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
||||
│
|
||||
├── bridges/
|
||||
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
||||
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
||||
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
||||
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||
│ └── raw_capture.py ← Simple raw capture tool
|
||||
│
|
||||
├── parsers/
|
||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||
│ └── frame_db.py ← SQLite frame database
|
||||
│
|
||||
└── docs/
|
||||
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### ACH inbound server (production)
|
||||
|
||||
Listens for inbound unit call-homes, downloads all new events and monitor log
|
||||
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
||||
|
||||
```bash
|
||||
python bridges/ach_server.py --port 12345 --output bridges/captures/
|
||||
```
|
||||
|
||||
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
|
||||
|
||||
Options:
|
||||
```
|
||||
--port N Listen port (default 12345)
|
||||
--output DIR Capture directory (default bridges/captures/)
|
||||
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
||||
--max-events N Safety cap for first run (default: unlimited)
|
||||
--clear-after-download Erase device memory after successful download
|
||||
--verbose Debug logging
|
||||
```
|
||||
|
||||
### SFM REST server
|
||||
|
||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||
|
||||
```bash
|
||||
python sfm/server.py # default: 0.0.0.0:8200
|
||||
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
|
||||
```
|
||||
|
||||
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
|
||||
for the interactive API docs.
|
||||
|
||||
### Seismo Lab GUI
|
||||
|
||||
```bash
|
||||
python seismo_lab.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SFM REST API
|
||||
|
||||
### Live device endpoints
|
||||
|
||||
Each call dials the device, does its work, and closes the connection. TCP
|
||||
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
||||
|
||||
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
||||
redundant TCP round-trips:
|
||||
|
||||
| Method | URL | Cache |
|
||||
|--------|-----|-------|
|
||||
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
|
||||
| `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected |
|
||||
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
|
||||
| `GET` | `/device/monitor/status` | 30-second TTL |
|
||||
| `POST` | `/device/connect` | — |
|
||||
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
||||
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
||||
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
||||
|
||||
All cached endpoints accept `?force=true` to bypass the cache.
|
||||
|
||||
Transport query params (supply one set):
|
||||
```
|
||||
Serial: ?port=COM5&baud=38400
|
||||
TCP: ?host=1.2.3.4&tcp_port=12345
|
||||
```
|
||||
|
||||
### DB read endpoints
|
||||
|
||||
Query the SQLite database written by `ach_server.py`. All read-only except
|
||||
`PATCH /db/events/{id}/false_trigger`.
|
||||
|
||||
| Method | URL | Description |
|
||||
|--------|-----|-------------|
|
||||
| `GET` | `/db/units` | All known serials with summary stats |
|
||||
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
||||
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
||||
| `GET` | `/db/sessions` | ACH call-home session history |
|
||||
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
||||
|
||||
---
|
||||
|
||||
## minimateplus library
|
||||
|
||||
```python
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
# Serial
|
||||
client = MiniMateClient(port="COM5")
|
||||
|
||||
# TCP (cellular modem)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||
|
||||
with client:
|
||||
# Read
|
||||
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
||||
count = client.count_events() # Number of stored events
|
||||
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
||||
events = client.get_events() # Full download: headers + peaks + metadata
|
||||
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
||||
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
||||
|
||||
# Write
|
||||
client.apply_config(
|
||||
sample_rate=1024,
|
||||
trigger_level_geo=0.5,
|
||||
project="Bridge Inspection 2026",
|
||||
client_name="City of Portland",
|
||||
operator="B. Harrison",
|
||||
)
|
||||
|
||||
# Control
|
||||
client.start_monitoring() # SUB 0x96
|
||||
client.stop_monitoring() # SUB 0x97
|
||||
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
||||
```
|
||||
|
||||
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
|
||||
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
|
||||
existed at record time — not backfilled from the current compliance config.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
||||
Three tables, all unit-keyed by serial number:
|
||||
|
||||
| Table | Key | Contents |
|
||||
|-------|-----|----------|
|
||||
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
|
||||
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag |
|
||||
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold |
|
||||
|
||||
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
|
||||
never produce duplicate rows. Post-erase key reuse is handled automatically
|
||||
via the high-water mark in `ach_state.json`.
|
||||
|
||||
---
|
||||
|
||||
## Connecting over cellular (RV50 / RV55)
|
||||
|
||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
||||
|
||||
### Required ACEmanager settings
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
||||
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
||||
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
||||
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
||||
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
||||
|
||||
---
|
||||
|
||||
## Protocol quick-reference
|
||||
|
||||
| Term | Value | Meaning |
|
||||
|------|-------|---------|
|
||||
| DLE | `0x10` | Data Link Escape |
|
||||
| STX | `0x02` | Start of frame |
|
||||
| ETX | `0x03` | End of frame |
|
||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||
|
||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install pyserial fastapi uvicorn
|
||||
```
|
||||
|
||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||
Windows (check "tcl/tk and IDLE" during install).
|
||||
|
||||
---
|
||||
|
||||
## Virtual COM ports (bridge capture)
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
|
||||
- [x] Write commands — push compliance config, trigger thresholds, project strings to device
|
||||
- [x] Erase all events — confirmed erase sequence from live MITM capture
|
||||
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
||||
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
|
||||
- [x] ACH inbound server — accept call-home connections, download events, dedup by key
|
||||
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
|
||||
- [x] SFM REST API — device control + DB query endpoints, live device cache
|
||||
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
|
||||
- [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first)
|
||||
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
|
||||
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||
# seismo-relay `v0.12.1`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing MiniMate Plus seismographs.
|
||||
|
||||
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
||||
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
||||
|
||||
> **Status:** Active development. Full read + write + erase + monitoring
|
||||
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
||||
> handles inbound unit connections, downloads events, and persists everything
|
||||
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||
|
||||
---
|
||||
|
||||
## What's in here
|
||||
|
||||
```
|
||||
seismo-relay/
|
||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||
│
|
||||
├── minimateplus/ ← MiniMate Plus client library
|
||||
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
||||
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
||||
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
||||
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
||||
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
||||
│
|
||||
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
||||
│ ├── server.py ← All device + DB endpoints
|
||||
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
||||
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
||||
│
|
||||
├── bridges/
|
||||
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
||||
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
||||
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
||||
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||
│ └── raw_capture.py ← Simple raw capture tool
|
||||
│
|
||||
├── parsers/
|
||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||
│ └── frame_db.py ← SQLite frame database
|
||||
│
|
||||
└── docs/
|
||||
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### ACH inbound server (production)
|
||||
|
||||
Listens for inbound unit call-homes, downloads all new events and monitor log
|
||||
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
||||
|
||||
```bash
|
||||
python bridges/ach_server.py --port 12345 --output bridges/captures/
|
||||
```
|
||||
|
||||
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
|
||||
|
||||
Options:
|
||||
```
|
||||
--port N Listen port (default 12345)
|
||||
--output DIR Capture directory (default bridges/captures/)
|
||||
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
||||
--max-events N Safety cap for first run (default: unlimited)
|
||||
--clear-after-download Erase device memory after successful download
|
||||
--verbose Debug logging
|
||||
```
|
||||
|
||||
### SFM REST server
|
||||
|
||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||
|
||||
```bash
|
||||
python sfm/server.py # default: 0.0.0.0:8200
|
||||
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
|
||||
```
|
||||
|
||||
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
|
||||
for the interactive API docs.
|
||||
|
||||
### Seismo Lab GUI
|
||||
|
||||
```bash
|
||||
python seismo_lab.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SFM REST API
|
||||
|
||||
### Live device endpoints
|
||||
|
||||
Each call dials the device, does its work, and closes the connection. TCP
|
||||
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
||||
|
||||
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
||||
redundant TCP round-trips:
|
||||
|
||||
| Method | URL | Cache |
|
||||
|--------|-----|-------|
|
||||
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
|
||||
| `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected |
|
||||
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
|
||||
| `GET` | `/device/monitor/status` | 30-second TTL |
|
||||
| `POST` | `/device/connect` | — |
|
||||
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
||||
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
||||
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
||||
|
||||
All cached endpoints accept `?force=true` to bypass the cache.
|
||||
|
||||
Transport query params (supply one set):
|
||||
```
|
||||
Serial: ?port=COM5&baud=38400
|
||||
TCP: ?host=1.2.3.4&tcp_port=12345
|
||||
```
|
||||
|
||||
### DB read endpoints
|
||||
|
||||
Query the SQLite database written by `ach_server.py`. All read-only except
|
||||
`PATCH /db/events/{id}/false_trigger`.
|
||||
|
||||
| Method | URL | Description |
|
||||
|--------|-----|-------------|
|
||||
| `GET` | `/db/units` | All known serials with summary stats |
|
||||
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
||||
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
||||
| `GET` | `/db/sessions` | ACH call-home session history |
|
||||
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
||||
|
||||
---
|
||||
|
||||
## minimateplus library
|
||||
|
||||
```python
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
# Serial
|
||||
client = MiniMateClient(port="COM5")
|
||||
|
||||
# TCP (cellular modem)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||
|
||||
with client:
|
||||
# Read
|
||||
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
||||
count = client.count_events() # Number of stored events
|
||||
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
||||
events = client.get_events() # Full download: headers + peaks + metadata
|
||||
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
||||
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
||||
|
||||
# Write
|
||||
client.apply_config(
|
||||
sample_rate=1024,
|
||||
trigger_level_geo=0.5,
|
||||
project="Bridge Inspection 2026",
|
||||
client_name="City of Portland",
|
||||
operator="B. Harrison",
|
||||
)
|
||||
|
||||
# Control
|
||||
client.start_monitoring() # SUB 0x96
|
||||
client.stop_monitoring() # SUB 0x97
|
||||
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
||||
```
|
||||
|
||||
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
|
||||
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
|
||||
existed at record time — not backfilled from the current compliance config.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
||||
Three tables, all unit-keyed by serial number:
|
||||
|
||||
| Table | Key | Contents |
|
||||
|-------|-----|----------|
|
||||
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
|
||||
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag |
|
||||
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold |
|
||||
|
||||
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
|
||||
never produce duplicate rows. Post-erase key reuse is handled automatically
|
||||
via the high-water mark in `ach_state.json`.
|
||||
|
||||
---
|
||||
|
||||
## Connecting over cellular (RV50 / RV55)
|
||||
|
||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
||||
|
||||
### Required ACEmanager settings
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
||||
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
||||
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
||||
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
||||
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
||||
|
||||
---
|
||||
|
||||
## Protocol quick-reference
|
||||
|
||||
| Term | Value | Meaning |
|
||||
|------|-------|---------|
|
||||
| DLE | `0x10` | Data Link Escape |
|
||||
| STX | `0x02` | Start of frame |
|
||||
| ETX | `0x03` | End of frame |
|
||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||
|
||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install pyserial fastapi uvicorn
|
||||
```
|
||||
|
||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||
Windows (check "tcl/tk and IDLE" during install).
|
||||
|
||||
---
|
||||
|
||||
## Virtual COM ports (bridge capture)
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
|
||||
- [x] Write commands — push compliance config, trigger thresholds, project strings to device
|
||||
- [x] Erase all events — confirmed erase sequence from live MITM capture
|
||||
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
||||
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
|
||||
- [x] ACH inbound server — accept call-home connections, download events, dedup by key
|
||||
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
|
||||
- [x] SFM REST API — device control + DB query endpoints, live device cache
|
||||
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
|
||||
- [ ] 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
|
||||
|
||||
+800
-838
File diff suppressed because it is too large
Load Diff
+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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
diagnose_5a_frames.py -- Frame-by-frame diagnostic for SUB 5A waveform streams.
|
||||
|
||||
Usage:
|
||||
python diagnose_5a_frames.py [--host HOST] [--port PORT] [--event INDEX]
|
||||
|
||||
Connects to the device, downloads the waveform for the specified event (default 0 =
|
||||
most recently stored), and prints detailed per-frame info for every A5 response frame:
|
||||
|
||||
fi=N | db=NNN B w=NNN B | "Project:" in db=[offsets] in w=[offsets] <-- METADATA if detected
|
||||
w[0:32] = <hex>
|
||||
w[-8:] = <hex>
|
||||
[waveform bytes or ASCII snippet]
|
||||
|
||||
Then shows:
|
||||
- total non-metadata frames, total waveform bytes, total sample-sets decoded
|
||||
- compliance-config expected vs decoded counts
|
||||
- sample values at the flat-line onset region (~1700-1820)
|
||||
- first near-zero run location (|T| < 20 for 10+ consecutive samples)
|
||||
|
||||
Run with: python diagnose_5a_frames.py 2>&1 | tee /tmp/diag_output.txt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# -- Setup logging -------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING, # suppress library noise; we print our own output
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
log = logging.getLogger("diagnose")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def decode_int16_sets(wave: bytes, n: int = 8) -> list[tuple[int, int, int, int]]:
|
||||
"""Decode up to n sample-sets from wave bytes as [T, V, L, M] int16 LE."""
|
||||
sets = []
|
||||
for i in range(min(n, len(wave) // 8)):
|
||||
off = i * 8
|
||||
t = struct.unpack_from("<h", wave, off)[0]
|
||||
v = struct.unpack_from("<h", wave, off + 2)[0]
|
||||
l = struct.unpack_from("<h", wave, off + 4)[0]
|
||||
m = struct.unpack_from("<h", wave, off + 6)[0]
|
||||
sets.append((t, v, l, m))
|
||||
return sets
|
||||
|
||||
|
||||
def find_all(data: bytes, needle: bytes) -> list[int]:
|
||||
"""Return all offsets where needle appears in data."""
|
||||
positions = []
|
||||
start = 0
|
||||
while True:
|
||||
pos = data.find(needle, start)
|
||||
if pos < 0:
|
||||
break
|
||||
positions.append(pos)
|
||||
start = pos + 1
|
||||
return positions
|
||||
|
||||
|
||||
def sep(label: str = "") -> None:
|
||||
width = 80
|
||||
if label:
|
||||
pad = max(0, (width - len(label) - 2) // 2)
|
||||
print(f"\n{'-' * pad} {label} {'-' * max(0, width - pad - len(label) - 2)}")
|
||||
else:
|
||||
print("-" * width)
|
||||
|
||||
|
||||
def diagnose(frames_data: list[bytes], compliance_config=None) -> None:
|
||||
"""Analyse all A5 frames and print diagnostic info."""
|
||||
|
||||
sep("PER-FRAME ANALYSIS")
|
||||
print(f"Total A5 frames received: {len(frames_data)}")
|
||||
print()
|
||||
|
||||
all_chunks: list[tuple[int, bytes]] = [] # (fi, wave_bytes)
|
||||
cumulative_wave_bytes = 0
|
||||
|
||||
for fi, db in enumerate(frames_data):
|
||||
w = db[7:] # what _decode_a5_waveform sees (db[7:])
|
||||
|
||||
# Find "Project:" in both the full frame data and the w=db[7:] slice
|
||||
proj_in_db = find_all(db, b"Project:")
|
||||
proj_in_w = find_all(w, b"Project:")
|
||||
|
||||
# The live detector in client.py uses: b"Project:" in w
|
||||
detected_as_metadata = bool(proj_in_w)
|
||||
|
||||
flag = " <-- METADATA (skipped)" if detected_as_metadata else ""
|
||||
print(f"fi={fi:3d} db={len(db):5d}B w={len(w):5d}B "
|
||||
f"Project: in db={proj_in_db} in w(db[7:])={proj_in_w}{flag}")
|
||||
|
||||
hex_head = w[:32].hex(' ')
|
||||
hex_tail = w[-8:].hex(' ') if len(w) >= 8 else w.hex(' ')
|
||||
print(f" w[0:32] = {hex_head}")
|
||||
print(f" w[-8:] = {hex_tail}")
|
||||
|
||||
if fi == 0:
|
||||
sp = w.find(b"STRT")
|
||||
if sp >= 0:
|
||||
strt = w[sp:sp + 21]
|
||||
print(f" STRT at w[{sp}]: {strt.hex(' ')}")
|
||||
wave = w[sp + 21:]
|
||||
if wave:
|
||||
sets = decode_int16_sets(wave, 4)
|
||||
print(f" wave[sp+21:] first 4 sets (T,V,L,M): {sets}")
|
||||
all_chunks.append((fi, wave))
|
||||
cumulative_wave_bytes += len(wave)
|
||||
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8}")
|
||||
else:
|
||||
print(f" *** STRT NOT FOUND ***")
|
||||
|
||||
elif detected_as_metadata:
|
||||
# Print the ASCII content to confirm this is the real metadata frame
|
||||
try:
|
||||
snippet = w.decode("ascii", errors="replace")
|
||||
# Find the first 200 printable characters
|
||||
printable = snippet[:200].replace("\x00", ".").replace("\r", "\n").replace("\n", "\n")
|
||||
print(f" ASCII: {repr(printable[:140])}")
|
||||
except Exception as e:
|
||||
print(f" (decode error: {e})")
|
||||
|
||||
else:
|
||||
# Regular chunk: strip 8-byte header
|
||||
if len(w) >= 8:
|
||||
wave = w[8:]
|
||||
all_chunks.append((fi, wave))
|
||||
cumulative_wave_bytes += len(wave)
|
||||
sets = decode_int16_sets(wave, 4)
|
||||
# Count near-zero Tran values
|
||||
all_sets = decode_int16_sets(wave, len(wave) // 8)
|
||||
nz = sum(1 for s in all_sets if abs(s[0]) < 20)
|
||||
print(f" wave[8:] first 4 sets: {sets}")
|
||||
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8} "
|
||||
f"near-zero(|T|<20): {nz}/{len(all_sets)}")
|
||||
|
||||
print()
|
||||
|
||||
# -- Waveform value analysis ------------------------------------------------
|
||||
sep("WAVEFORM DECODE")
|
||||
|
||||
cc_sr = 1024
|
||||
cc_rt = None
|
||||
pretrig = 256
|
||||
total_expected = 0
|
||||
|
||||
if compliance_config:
|
||||
cc_sr = compliance_config.sample_rate or 1024
|
||||
cc_rt = compliance_config.record_time
|
||||
pretrig = int(round(0.25 * cc_sr))
|
||||
if cc_rt:
|
||||
total_expected = pretrig + int(round(cc_rt * cc_sr))
|
||||
print(f"Compliance: sr={cc_sr} sps record_time={cc_rt} s "
|
||||
f"pretrig={pretrig} total_expected={total_expected}")
|
||||
else:
|
||||
print("No compliance config -- using defaults: sr=1024, pretrig=256")
|
||||
|
||||
total_wave_bytes = sum(len(w) for _, w in all_chunks)
|
||||
total_sets_raw = total_wave_bytes // 8
|
||||
print(f"Non-metadata frames: {len(all_chunks)} "
|
||||
f"Total wave bytes: {total_wave_bytes} "
|
||||
f"Raw sample-sets: {total_sets_raw}")
|
||||
|
||||
# Alignment-corrected decode (matches _decode_a5_waveform exactly)
|
||||
tran: list[int] = []
|
||||
running_offset = 0
|
||||
for fi, wave in all_chunks:
|
||||
align = running_offset % 8
|
||||
skip = (8 - align) % 8
|
||||
if skip > 0 and skip < len(wave):
|
||||
usable = wave[skip:]
|
||||
elif align == 0:
|
||||
usable = wave
|
||||
else:
|
||||
running_offset += len(wave)
|
||||
continue
|
||||
n_usable = len(usable) // 8
|
||||
for i in range(n_usable):
|
||||
tran.append(struct.unpack_from("<h", usable, i * 8)[0])
|
||||
running_offset += len(wave)
|
||||
|
||||
n_decoded = len(tran)
|
||||
print(f"Alignment-corrected decoded Tran samples: {n_decoded}")
|
||||
if compliance_config and cc_rt:
|
||||
print(f"Expected: {total_expected} Decoded: {n_decoded} "
|
||||
f"Excess (tail): {max(0, n_decoded - total_expected)}")
|
||||
|
||||
print()
|
||||
print(f"First 16 Tran: {tran[:16]}")
|
||||
if n_decoded >= 32:
|
||||
print(f"Last 16 Tran: {tran[-16:]}")
|
||||
|
||||
# -- Flat-line onset search -------------------------------------------------
|
||||
sep("FLAT-LINE ONSET (first run of 10+ consecutive |Tran| < 20)")
|
||||
|
||||
run_start = None
|
||||
run_len = 0
|
||||
onset_found = False
|
||||
for i, v in enumerate(tran):
|
||||
if abs(v) < 20:
|
||||
if run_start is None:
|
||||
run_start = i
|
||||
run_len += 1
|
||||
else:
|
||||
if run_len >= 10:
|
||||
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
|
||||
print(f" First near-zero run: sample {run_start}-{run_start + run_len - 1} "
|
||||
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
|
||||
onset_found = True
|
||||
break
|
||||
run_start = None
|
||||
run_len = 0
|
||||
else:
|
||||
if run_len >= 10 and run_start is not None:
|
||||
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
|
||||
print(f" Near-zero run at end: sample {run_start}-{n_decoded - 1} "
|
||||
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
|
||||
onset_found = True
|
||||
|
||||
if not onset_found:
|
||||
print(" No near-zero run of 10+ samples found (waveform looks active throughout)")
|
||||
|
||||
# Print samples around the expected flat-line onset (~1700-1820)
|
||||
if n_decoded >= 1700:
|
||||
print()
|
||||
print("Tran samples [1700:1820] (10 per line):")
|
||||
for row_start in range(1700, min(1820, n_decoded), 10):
|
||||
row = tran[row_start:row_start + 10]
|
||||
t_ms_row = (row_start - pretrig) * 1000.0 / cc_sr
|
||||
print(f" [{row_start:4d}] (t={t_ms_row:6.1f}ms): {row}")
|
||||
else:
|
||||
print(f" Only {n_decoded} samples decoded -- range 1700-1820 not available")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Diagnose A5 5A waveform frames")
|
||||
parser.add_argument("--host", default="63.43.212.232", help="Device IP")
|
||||
parser.add_argument("--port", type=int, default=9034, help="TCP port")
|
||||
parser.add_argument("--event", type=int, default=0, help="Event index (0=first stored)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Connecting to {args.host}:{args.port} ...")
|
||||
print(f"Target event index: {args.event}")
|
||||
print()
|
||||
|
||||
transport = TcpTransport(args.host, port=args.port)
|
||||
with MiniMateClient(transport=transport) as client:
|
||||
info = client.connect()
|
||||
print(f"Device: serial={info.serial} firmware={info.firmware_version}")
|
||||
compliance_config = info.compliance_config
|
||||
if compliance_config:
|
||||
print(f"Compliance: sample_rate={compliance_config.sample_rate} "
|
||||
f"record_time={compliance_config.record_time}")
|
||||
print()
|
||||
|
||||
proto = client._proto
|
||||
assert proto is not None
|
||||
|
||||
# -- Walk to the target event ------------------------------------------
|
||||
log.info("Reading first event key (SUB 1E) ...")
|
||||
first_key4, first_data8 = proto.read_event_first(token=0)
|
||||
print(f"First event key: {first_key4.hex()}")
|
||||
|
||||
cur_key4 = first_key4
|
||||
cur_data8 = first_data8
|
||||
event_idx = 0
|
||||
|
||||
while event_idx < args.event:
|
||||
# 0A required before each 1F to establish device context
|
||||
proto.read_waveform_header(cur_key4)
|
||||
next_key4, next_data8 = proto.advance_event(browse=True)
|
||||
if next_data8[4:8] == b"\x00\x00\x00\x00":
|
||||
print(f"Only {event_idx + 1} events available; cannot reach index {args.event}")
|
||||
return
|
||||
cur_key4 = next_key4
|
||||
cur_data8 = next_data8
|
||||
event_idx += 1
|
||||
print(f" advanced to event {event_idx}: key={cur_key4.hex()}")
|
||||
|
||||
print(f"\nDownloading event {args.event}: key={cur_key4.hex()}")
|
||||
|
||||
# -- Full download sequence (matches get_events download-mode) ---------
|
||||
log.info("0A: read_waveform_header ...")
|
||||
proto.read_waveform_header(cur_key4)
|
||||
|
||||
log.info("1E(0xFE): arm device for 5A ...")
|
||||
proto.read_event_first(token=0xFE)
|
||||
|
||||
log.info("0C: read_waveform_record ...")
|
||||
wfm_raw = proto.read_waveform_record(cur_key4)
|
||||
print(f"0C waveform record: {len(wfm_raw)} bytes")
|
||||
|
||||
log.info("1F(0xFE): arm 5A state machine ...")
|
||||
arm_key4, _ = proto.advance_event(browse=False)
|
||||
print(f"1F(arm) returned key: {arm_key4.hex()}")
|
||||
|
||||
log.info("POLLx3 ...")
|
||||
for i in range(3):
|
||||
proto.poll()
|
||||
print(f" POLL {i+1}/3 OK")
|
||||
|
||||
print(f"\nStarting 5A bulk stream for key={cur_key4.hex()} ...")
|
||||
frames_data = proto.read_bulk_waveform_stream(
|
||||
cur_key4,
|
||||
stop_after_metadata=False,
|
||||
max_chunks=2048,
|
||||
)
|
||||
print(f"5A complete: {len(frames_data)} A5 frames")
|
||||
print()
|
||||
|
||||
# -- Run the diagnostic ------------------------------------------------
|
||||
diagnose(frames_data, compliance_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -36,7 +36,7 @@
|
||||
| 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. |
|
||||
| 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0–255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. |
|
||||
| 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. |
|
||||
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → likely internal ADC full-scale calibration constant or hardware range ceiling. Downgraded to LOW priority. |
|
||||
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but was NOT confirmed at this time. Using it directly as the range produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. **RESOLVED 2026-04-17 — see §7.6.2 and changelog entry.** |
|
||||
| 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. |
|
||||
| 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. |
|
||||
| 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. |
|
||||
@@ -92,7 +92,7 @@
|
||||
| 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. |
|
||||
| 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 1–16 have varying data lengths (1036–1123 bytes); chunks 17–35 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. |
|
||||
| 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. |
|
||||
| 2026-04-06 | §7.8 | **CONFIRMED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion: `value = counts × (range / 32767)`. For geo channels: range = 10.000 in/s (from the device's compliance config geo range field). For the mic channel: range is in psi (device-specific). Near-full-scale counts (≈32,700) on all four channels simultaneously indicate ADC saturation (clipping) from a high-amplitude event. |
|
||||
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value was UNKNOWN at time of writing. **UPDATED 2026-04-17:** `max_range_geo` = 6.206053 is confirmed as the ADC-to-velocity scale factor (inverse sensitivity, (in/s)/V). The correct conversion is therefore: `PPV (in/s) = counts × (1.61133 / 32767) × 6.206053` = `counts × 4.982e-5` in/s per count. The earlier ~9× overread from using 6.206053 directly as the range was because the range IS 1.61133 × 6.206053 = 10.000 in/s, not 6.206053. See §7.6.2 for the confirmed field layout. |
|
||||
| 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. |
|
||||
| 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. |
|
||||
| 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. |
|
||||
@@ -103,6 +103,13 @@
|
||||
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes −8:−4) and last stored event key (bytes −4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
|
||||
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
|
||||
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
|
||||
| 2026-04-17 | §7.6.2, §14 | **RESOLVED — Float 6.206053 at channel_label+28 is the ADC-to-velocity scale factor.** Confirmed from Series III Interface Handbook §4.5 formula: `Range (×1) = 1.61133 V / Sensitivity (V/unit)`. For the standard Instantel geophone at Normal range (10.000 in/s): Sensitivity = 1.61133 / 10 = 0.161133 V/(in/s). The stored value is the **inverse sensitivity** = 1/0.161133 = **6.206053 (in/s)/V**. Cross-check: 1.61133 V × 6.206053 = 10.000 in/s ✅. The firmware uses it as: `PPV (in/s) = ADC_voltage (V) × 6.206053`. Value is identical on all Instantel standard geophones — it is a hardware/firmware constant, NOT a user-configurable setting. Do NOT write this field. Open question §14 item "Max Geo Range float 6.2061" is now **RESOLVED**. |
|
||||
| 2026-04-20 | §7.6.4 (NEW), §7.9, Appendix B | **CONFIRMED — Recording Mode byte location.** Three targeted captures (4-20-26) confirmed `recording_mode` at anchor−8 in both the E5 read payload and the BW write payload (6-byte anchor `\xbe\x80\x00\x00\x00\x00`). BW write payload and E5 read payload are **byte-identical** around the anchor region — Blastware round-trips the wire-encoded E5 bytes verbatim with only the target field modified. Anchor position varies by ±1 depending on whether recording_mode = 0x03 (Histogram), because E5 wire-encodes `0x03` as the inner DLE+ETX pair `\x10\x03` (2 bytes), which S3FrameParser preserves as two literal bytes in `compliance_raw`. Enum: `0x00`=Single Shot, `0x01`=Continuous, `0x03`=Histogram, `0x04`=Histogram+Continuous. `0x02` value not yet observed. The byte at anchor−9 is `0x00` for Single Shot / Continuous, and `0x10` for Histogram (DLE prefix from E5 encoding) and Histogram+Continuous (actual config byte). See §7.6.4 for full details. |
|
||||
| 2026-04-21 | Appendix D (NEW) | **NEW — Blastware .N00 and .MLG file formats fully decoded.** `minimateplus/blastware_file.py` implements `write_n00()` and `write_mlg()`. N00 file format confirmed: 22B header + 21B STRT record + variable body + 26B footer. Body reconstructed from A5 bulk waveform stream frames with per-frame skip amounts (probe=7+strt_pos+21, A5[1]=13, A5[2+]=12, terminator=11) and DLE strip rule (strip `0x10` before `{0x02,0x03,0x04}`, keep following byte). Footer extracted verbatim from terminator frame's last 26 bytes. Split-pair edge case: when `frame.data[-1]==0x10` and `chk_byte∈{0x02,0x03,0x04}`, reunite both bytes before stripping and always remove trailing chk_byte (`stripped[:-1]`) — chk_byte is checksum, not payload. STRT record must be copied verbatim from A5[0]; bytes [10:20] are device-specific and cannot be reconstructed from Event fields. `write_n00` verified byte-perfect against `M529LIY6.N00` from 4-3-26-multi_event capture. MLG format: 308B header + N×292B records; CRC algorithm unknown (write as 0x0000). |
|
||||
| 2026-04-21 | Appendix D §D.5 (NEW) | **NEW — Blastware filename encoding fully decoded.** Serial prefix: `chr(ord('B') + floor(serial/1000))` + last 3 digits zero-padded. Stem: 4-char base-36 of `floor(total_seconds/1296)`. Extension: `AB0` for manual/direct downloads (3 chars), `AB0W` or `AB0H` for ACH/call-home downloads (4 chars), where `AB` = 2-char base-36 of `total_seconds % 1296` and W/H = waveform/histogram. Epoch = 1985-01-01 00:00:00 device local time. Confirmed against 3,248 files from 10-year production archive with zero errors. 3-day cycle property: same daily recording time cycles through 3 extensions (864s/day shift, period=3 days). `blastware_filename(event, serial, ach=False)` implements full formula. |
|
||||
| 2026-04-21 | §7.6.2, §5.3 | **CORRECTED — compliance_raw contains wire-encoded bytes, NOT logical bytes.** S3FrameParser appends DLE+ETX inner-frame pairs as two literal bytes to the frame body. Any `0x03` values in the compliance config appear in `compliance_raw` as `\x10\x03` (two bytes), not as a single `0x03`. The previous claim "S3FrameParser handles this transparently so compliance_raw contains logical (destuffed) bytes" was wrong. Consequence: `compliance_raw` is the wire-encoded E5 payload; anchor-relative reads work correctly because the anchor position automatically accounts for any DLE-encoded bytes before it. For write-back, round-tripping `compliance_raw` verbatim sends the correct wire bytes to the device. **DLE ETX escaping in write frames:** Blastware escapes `0x03` bytes in write frame data as `\x10\x03` on wire; our `build_bw_write_frame` does not (writes data raw). Device is confirmed to accept raw writes for all tested modes — likely uses the offset/length field for write frame framing, not ETX scanning. |
|
||||
| 2026-04-20 | §7.6.2, §7.9, Appendix B | **CONFIRMED — Geophone maximum range / sensitivity selector byte location.** Two targeted captures (4-20-26, geo sensitivity folder): one at Normal 10.000 in/s, one at Sensitive 1.250 in/s. E5 read payload diff: exactly 3 bytes differ at channel_label+33 for Tran/Vert/Long. Values: `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. Same offset applies to the SUB 71 write payload (which is the same 2126-byte E5-format buffer round-tripped verbatim). **`channel_label+20` reads `0x01` in ALL captures regardless of range setting — it is NOT this field.** Previous hypothesis (uint8 at Tran+20, 0x01=Normal) was WRONG. Stored as `geo_range` in `ComplianceConfig`. Encoded to all three geo channel blocks (Tran/Vert/Long) at label+33. |
|
||||
| 2026-04-20 | §5.1, §5.3, §7.12 (NEW) | **NEW — Auto Call Home config protocol confirmed from 4-20-26 call home settings captures.** SUB 0x2C (Call Home Config READ, response 0xD3, data offset 0x7C=124) and SUB 0x7E/0x7F (WRITE + CONFIRM, response 0x81/0x80) confirmed. Write payload = read payload (125 bytes) + `\x00\x00` (127 bytes total). **DLE-escaped ETX at raw[117:119]:** the device returns logical value 0x03 (num_retries=3) as `\x10\x03` on the wire — S3FrameParser preserves both bytes as two literals, causing a +1 byte shift for all subsequent fields. Write frame sends these bytes verbatim (device interprets `\x10\x03` as literal value 3). Field map confirmed from 10-frame BW TX diff. See §7.12 for full layout. |
|
||||
|
||||
---
|
||||
|
||||
@@ -257,13 +264,14 @@ Step 4 — Device sends actual data payload:
|
||||
| `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED |
|
||||
| `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED |
|
||||
| `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED |
|
||||
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries sample_rate (uint16 BE at anchor−2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. | ✅ CONFIRMED 2026-04-02 |
|
||||
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries recording_mode (uint8 at anchor−4 in E5 sf1), sample_rate (uint16 BE at anchor−2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. See §7.6.4 for recording_mode enum. | ✅ CONFIRMED 2026-04-02; recording_mode added 2026-04-20 |
|
||||
| `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED |
|
||||
| `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x0000–0x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 46–47 bytes IDLE, 48–49 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
|
||||
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
|
||||
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `2C` | **CALL HOME CONFIG READ** | Two-step read, data offset 0x7C (124 bytes + 1-byte DLE artefact = 125 raw bytes). Returns Auto Call Home configuration: enable flag, dial string, scheduled call times, retry settings, modem timing. Response SUB = 0xD3. **DLE note:** logical value 0x03 (num_retries) is returned as `\x10\x03` on the wire, which S3FrameParser preserves as two literal bytes — this shifts all subsequent field positions by +1. See §7.12 for full field map. | ✅ CONFIRMED 2026-04-20 |
|
||||
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
|
||||
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
|
||||
|
||||
@@ -293,6 +301,7 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
|
||||
| `98` | `67` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `96` | `69` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `97` | `68` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
|
||||
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
|
||||
|
||||
@@ -314,6 +323,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
|
||||
| `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED |
|
||||
| `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED |
|
||||
| `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED |
|
||||
| `7E` | **CALL HOME CONFIG WRITE** | Writes Auto Call Home configuration (127 bytes: 125-byte read payload + `\x00\x00`). Offset = data[1]+2 = 0x7E. Write format (DLE-aware checksum, only BW_CMD `0x10` doubled on wire). Response SUB = 0x81. Must be followed by SUB 0x7F confirm. | `81` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `7F` | **CALL HOME WRITE CONFIRM** | Short frame, no data. Commits call home config write from SUB 0x7E. Response SUB = 0x80. | `80` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED |
|
||||
| `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED |
|
||||
|
||||
@@ -327,6 +338,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
|
||||
| `72` | `8D` |
|
||||
| `73` | `8C` |
|
||||
| `74` | `8B` |
|
||||
| `7E` | `81` |
|
||||
| `7F` | `80` |
|
||||
| `82` | `7D` |
|
||||
| `83` | `7C` |
|
||||
|
||||
@@ -528,7 +541,7 @@ The SUB `1A` read response (`E5`) and SUB `71` write block contain per-channel t
|
||||
| Field | Example bytes | Decoded | Certainty |
|
||||
|---|---|---|---|
|
||||
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
|
||||
| Max range float | `40 C6 97 FD` | 6.206 — full-scale range in in/s | 🔶 INFERRED |
|
||||
| ADC scale factor | `40 C6 97 FD` | **6.206053 (in/s)/V — CONFIRMED 2026-04-17.** This is the inverse sensitivity of the standard Instantel geophone = 1/0.161133. Interface Handbook §4.5: `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Used by firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant — do NOT write. | ✅ CONFIRMED |
|
||||
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
|
||||
| **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
|
||||
| Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED |
|
||||
@@ -620,6 +633,53 @@ The sample rate bytes sit immediately before a `0x10` (DLE) prefix byte in the r
|
||||
|
||||
---
|
||||
|
||||
### 7.6.4 Recording Mode
|
||||
|
||||
> ✅ **CONFIRMED — 2026-04-20** (BE11529 / firmware S338.17). Three targeted captures in a single Blastware session (4-20-26 directory), changing Recording Mode only between each write.
|
||||
|
||||
Recording mode is stored as a **uint8** with different anchor-relative positions depending on whether you are reading from a device response or constructing a write payload.
|
||||
|
||||
**In the SUB 71 write payload (3-chunk compliance write, `cfg[5]`):**
|
||||
|
||||
| Enum | Mode |
|
||||
|---|---|
|
||||
| `0x00` | Single Shot |
|
||||
| `0x01` | Continuous |
|
||||
| `0x02` | Unknown (not yet observed) |
|
||||
| `0x03` | Histogram |
|
||||
| `0x04` | Histogram + Continuous (combined mode) |
|
||||
|
||||
Anchor-relative position: **anchor − 3** (3 bytes before the 10-byte anchor in the write payload). The write payload layout in the region around the anchor:
|
||||
|
||||
```
|
||||
cfg[anchor - 3] = recording_mode (uint8)
|
||||
cfg[anchor - 2] = sample_rate_hi (uint8, MSB of uint16 BE)
|
||||
cfg[anchor - 1] = sample_rate_lo (uint8, LSB of uint16 BE)
|
||||
cfg[anchor:anchor+10] = \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 ← anchor
|
||||
cfg[anchor + 10:anchor + 14] = record_time (float32 BE)
|
||||
```
|
||||
|
||||
**In the E5 read response (sub-frame 1, page=`0x0010`, `data[17]`):**
|
||||
|
||||
The anchor appears at `data[21]` in this sub-frame. Recording mode is at `data[17]` = **anchor − 4** (one position earlier than in the write payload). This is because an extra `0x10` byte is present at `data[18]` in the read format (between recording_mode and sample_rate), which is NOT present in the write payload. The read-format layout:
|
||||
|
||||
```
|
||||
data[17] = recording_mode (uint8)
|
||||
data[18] = 0x10 ← extra byte present in E5 read only; absent in SUB 71 write
|
||||
data[19] = sample_rate_hi (uint8, MSB of uint16 BE)
|
||||
data[20] = sample_rate_lo (uint8, LSB of uint16 BE)
|
||||
data[21:31] = anchor (\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00)
|
||||
data[31:35] = record_time (float32 BE)
|
||||
```
|
||||
|
||||
**Chunk checksum at `cfg[1024]`:** The first of the three SUB 71 write chunks (1027 bytes) contains a running checksum byte at `cfg[1024]` whose delta exactly equals the delta of `cfg[5]` (recording_mode). This byte reflects the cumulative change from `recording_mode` through to its position and should not be mistaken for a second copy of the recording_mode field.
|
||||
|
||||
**Decode path (`_decode_compliance_config_into`):** use `data[anchor_pos - 4]` where `anchor_pos` is the index of the first byte of the anchor in the assembled E5 cfg bytes.
|
||||
|
||||
**Encode path (`_encode_compliance_config`):** use `cfg[anchor_pos - 3]` = recording_mode value (write-payload offset; no extra `0x10` byte).
|
||||
|
||||
---
|
||||
|
||||
### 7.7 Blastware `.set` File Format
|
||||
|
||||
> 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads.
|
||||
@@ -655,7 +715,7 @@ offset size type value (Tran example) meaning
|
||||
+10 2 uint16 0x0015 = 21 unknown
|
||||
+12 4 bytes 03 02 04 01 flags (recording mode etc.)
|
||||
+16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED
|
||||
+1A 4 float32 6.2061 max range (in/s for geo, psi for mic)
|
||||
+1A 4 float32 6.206053 ✅ CONFIRMED 2026-04-17 — ADC-to-velocity scale factor (= 1/sensitivity = (in/s)/V). Interface Handbook §4.5: Range = 1.61133 V × 6.206053 = 10.000 in/s (Normal range). Firmware uses: PPV (in/s) = ADC_voltage × 6.206053. Hardware constant — identical on all tested units. Do NOT write.
|
||||
+1E 2 00 00 padding
|
||||
+20 4 float32 0.6000 trigger level ✅ CONFIRMED
|
||||
+24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic)
|
||||
@@ -1171,26 +1231,52 @@ Two critical differences from `build_bw_frame`:
|
||||
| Frame | offset_word | counter | params | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| Probe | `0x1004` | `0x0000` | 10 bytes (`bulk_waveform_params(0)`) | Initiate transfer |
|
||||
| Chunk 1 | `0x1004` | `0x0400` | 11 bytes | First data chunk |
|
||||
| Chunk 2 | `0x1004` | `0x0800` | 11 bytes | Second chunk |
|
||||
| Chunk N | `0x1004` | `N * 0x0400` | 11 bytes | Nth chunk |
|
||||
| Chunk 1 | `0x1004` | `max(key4[2:4], 0x0400)` | 11 bytes | First data chunk |
|
||||
| Chunk 2 | `0x1004` | `max(key4[2:4], 0x0400) + 0x0400` | 11 bytes | Second chunk |
|
||||
| Chunk N | `0x1004` | `max(key4[2:4], 0x0400) + (N-1) * 0x0400` | 11 bytes | Nth chunk |
|
||||
| … | … | … | … | … |
|
||||
| Termination | `0x005A` | `last + 0x0400` | 10 bytes | End transfer |
|
||||
| Termination | `0x005A` | `max(key4[2:4], 0x0400) + N * 0x0400` | 10 bytes | End transfer |
|
||||
|
||||
> ⚠️ **2026-04-06 CORRECTED — chunk counter is monotonic for ALL chunks.**
|
||||
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1, which was hardcoded as a
|
||||
> special case. This was a Blastware artifact. Empirically confirmed: counter=0x0400 for
|
||||
> chunk 1 works correctly; counter=0x1004 causes the device to time out. The device does
|
||||
> NOT strictly validate the counter value — it streams data for any valid 5A request for
|
||||
> the given key. Use `chunk_num * 0x0400` (monotonic) for all chunks.
|
||||
> BW's true internal formula is `key4[2:4] + n * 0x0400`. For event 1 (key `01110000`)
|
||||
> this equals `n * 0x0400` since `key4[2:4] = 0x0000`. The monotonic formula is correct
|
||||
> for all keys encountered on this device.
|
||||
> ⚠️ **2026-04-06 CORRECTED — chunk counter is `key4[2:4] + (N-1) * 0x0400`.**
|
||||
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1 of key `01110000`, leading to
|
||||
> an interim "monotonic n * 0x0400" formula. This was accidentally correct because
|
||||
> `key4[2:4] == 0x0000` for that event.
|
||||
>
|
||||
> **2026-04-24 CORRECTION:** The counter is an absolute circular-buffer address.
|
||||
> BW's true formula is `key4[2:4] + (chunk_num - 1) * 0x0400` where `key4[2:4]` is the
|
||||
> event's storage base offset (`(key4[2]<<8) | key4[3]`). For keys where
|
||||
> `key4[2:4] != 0x0000` (e.g. key `01111884`), using `n * 0x0400` sends requests into the
|
||||
> wrong buffer region — the device returns data from a completely different event.
|
||||
>
|
||||
> **2026-04-26 FINAL CORRECTION:** The formula `key4[2:4] + (N-1) * 0x0400` is wrong when
|
||||
> `key4[2:4] == 0x0000` (e.g. event key `01110000`, the very first event after a device erase).
|
||||
> Counter=0x0000 for chunk 1 is the same address as the probe frame — the device re-returns
|
||||
> the STRT record data instead of waveform payload (frame 1 has len=1097, same as probe, and
|
||||
> contains `b"STRT\xff\xfe"`, contributing zero waveform bytes).
|
||||
> Final formula: `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400`.
|
||||
> For key `01110000`: chunk 1 = 0x0400 (confirmed working, empirical test 2026-04-06).
|
||||
> For key `0111245a`: chunk 1 = 0x245a (unchanged, confirmed from 4-3-26 capture).
|
||||
|
||||
The `stop_after_metadata=True` flag causes the loop to stop as soon as `b"Project:"` is
|
||||
found in the accumulated A5 frame data, typically after 7–9 chunks. A termination frame
|
||||
found in the accumulated A5 frame data, typically after 4–9 chunks. A termination frame
|
||||
is always sent before returning.
|
||||
|
||||
**IMPORTANT — one extra chunk required after "Project:" for valid file footer (confirmed 2026-04-23):**
|
||||
When writing a Blastware-compatible waveform file, stopping immediately at "Project:" and
|
||||
sending termination produces an empty termination response with no footer bytes (`0e 08`
|
||||
marker missing). Blastware downloads exactly **one more chunk** after finding "Project:"
|
||||
before sending termination — that extra chunk primes the device to return valid footer
|
||||
bytes (monitoring start/stop timestamps) in the termination response.
|
||||
|
||||
`read_bulk_waveform_stream(stop_after_metadata=True)` implements this: after the "Project:"
|
||||
chunk is received, one additional chunk is requested before breaking. The termination
|
||||
response (`include_terminator=True`) then contains the correct `0e 08` footer.
|
||||
|
||||
**do NOT use `full_waveform=True` for Blastware file writing** — for events with long
|
||||
post-event silence (35 chunks), the silence chunks contain embedded device-internal
|
||||
pointer structures that produce spurious STRT markers in the file body. Blastware only
|
||||
downloads 4–5 chunks (metadata + one signal chunk) regardless of event length.
|
||||
|
||||
#### 7.8.3 A5 Frame Layout
|
||||
|
||||
Each A5 response frame contains a chunk of raw bulk data. Frame 7 of the stream carries the
|
||||
@@ -1235,13 +1321,19 @@ TimeoutError caught:
|
||||
|
||||
Chunks with uniform 1,036-byte payload (chunks 17–35 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream.
|
||||
|
||||
**ADC count-to-physical conversion:**
|
||||
**ADC count-to-physical conversion — ✅ CONFIRMED 2026-04-17:**
|
||||
|
||||
Raw samples are signed 16-bit integers (−32,768 to +32,767). Source: Interface Handbook §4.5.
|
||||
|
||||
**CONFIRMED 2026-04-17** — The `max_range_geo` field (float32 = 6.206053, bytes `40 C6 97 FD`) is the **ADC-to-velocity scale factor** (inverse sensitivity, (in/s)/V) for the standard Instantel geophone, confirmed from Interface Handbook §4.5. The correct conversion formula is:
|
||||
|
||||
Raw samples are signed 16-bit integers (−32,768 to +32,767). To convert to physical units:
|
||||
```
|
||||
value_in_s (in/s) = counts × (geo_range / 32767)
|
||||
PPV (in/s) = ADC_voltage (V) × 6.206053
|
||||
= counts × (1.61133 / 32767) × 6.206053
|
||||
= counts × 4.982e-5 (in/s per count at full scale)
|
||||
```
|
||||
where `geo_range` is from the compliance config (typically 10.000 in/s). Mic channel uses psi units with its own range. Near-full-scale values on all channels simultaneously indicate ADC saturation (clipping).
|
||||
|
||||
where `geo_range = 1.61133 V × 6.206053 = 10.000 in/s` is the Normal (Gain=1) full-scale range. The earlier ~9× overread was caused by mistakenly using 6.206053 as the range directly — it is actually the scale factor, and the range itself is `ADC_fullscale × scale_factor = 1.61133 × 6.206053 = 10.000 in/s`. Mic channel uses psi units with its own range (still unresolved).
|
||||
|
||||
**Known decoder issue — fi==9 hardcoded skip:**
|
||||
|
||||
@@ -1257,8 +1349,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
|
||||
|
||||
| Field | Values / Type | Status |
|
||||
|---|---|---|
|
||||
| Recording Mode | Continuous / Single Shot / Histogram | ❓ |
|
||||
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ |
|
||||
| Recording Mode | Single Shot (`0x00`) / Continuous (`0x01`) / Histogram (`0x03`) / Histogram+Continuous (`0x04`) | ✅ `recording_mode` — write: `cfg[anchor−3]`; read E5 sf1: `data[anchor−4]` — confirmed 2026-04-20 |
|
||||
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ Hint: `data[40]` in E5 sf1 changed `01 7F` → `00 00` alongside Continuous → Single Shot; may be related but unconfirmed independently |
|
||||
| Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor−2) |
|
||||
| Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) |
|
||||
| Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ |
|
||||
@@ -1267,7 +1359,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
|
||||
| Geophone — Enable all | bool | ❓ |
|
||||
| Geophone — Trigger Source | bool | ❓ |
|
||||
| Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` |
|
||||
| Chan 1-3 Maximum Range | Normal 10.000 / 1.25 in/s | ✅ `max_range_geo` |
|
||||
| Chan 1-3 Maximum Range (range selector) | Normal 10.000 / 1.25 in/s | ✅ `geo_range` uint8 — **CONFIRMED 2026-04-20.** Offset = Tran+33 (same in E5 read and SUB 71 write — 2126-byte buffer is round-tripped verbatim). `0x00`=Normal 10 in/s, `0x01`=Sensitive 1.25 in/s. Applied to Tran/Vert/Long. **`Tran+20` is NOT this field** (constant 0x01 on all captures). |
|
||||
| Chan 1-3 ADC Scale Factor | 6.206053 (in/s)/V | ✅ `geo_adc_scale` float32 — **CONFIRMED 2026-04-17.** Offset = Tran+28 (same in E5 read and SUB 71 write). Inverse sensitivity = 1/0.161133. Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s. Hardware constant — do NOT write. |
|
||||
| Microphone — Enable all | bool | ❓ |
|
||||
| Microphone — Trigger Source | bool | ❓ |
|
||||
| Chan 4 Trigger Level | float, dB or psi | ❓ |
|
||||
@@ -1465,6 +1558,117 @@ and applies this heuristic on every call-home.
|
||||
|
||||
---
|
||||
|
||||
### 7.12 Auto Call Home Config (SUB 0x2C / 0x7E / 0x7F) — ✅ CONFIRMED 2026-04-20
|
||||
|
||||
> Confirmed from `bridges/captures/4-20-26/call home settings/` — 10 BW TX write frames
|
||||
> diffed against the S3 read payload. Accessible in Blastware via Remote Access → Setup Unit.
|
||||
|
||||
#### 7.12.1 Read Protocol — SUB 0x2C → Response 0xD3
|
||||
|
||||
Standard two-step read:
|
||||
|
||||
| Step | Offset | Purpose |
|
||||
|---|---|---|
|
||||
| Probe | `0x0000` | Get ack (no data returned) |
|
||||
| Data | `0x007C` (124) | Receive 125-byte raw payload |
|
||||
|
||||
`DATA_LENGTHS[SUB_CALL_HOME] = 0x7C`
|
||||
|
||||
The raw payload is accessed as `data_rsp.data[11:]` — this is 125 bytes (not 124) because
|
||||
the device returns logical value 0x03 (num_retries=3) as the two-byte wire sequence
|
||||
`\x10\x03`. S3FrameParser is in `STATE_IN_FRAME` when it sees `0x10`, transitions to
|
||||
`STATE_AFTER_DLE`, and then on `0x03` (ETX qualifier) it would normally end the frame —
|
||||
but in the `_IN_FRAME_DLE` state it instead appends **both** the `0x10` and the `0x03`
|
||||
literally to the payload. The result: `raw[117] = 0x10`, `raw[118] = 0x03`, and all
|
||||
subsequent fields are shifted +1 from their logical positions.
|
||||
|
||||
#### 7.12.2 Raw Payload Field Map (125 bytes, from `data_rsp.data[11:]`)
|
||||
|
||||
> All offsets are into the 125-byte raw array. Offsets ≥ 119 are shifted +1 from logical
|
||||
> due to the DLE-escaped 0x03 at raw[117:119].
|
||||
|
||||
| Raw Offset | Field | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `[5]` | `auto_call_home_enabled` | uint8 | `0x00` = disabled, `0x01` = enabled |
|
||||
| `[6:46]` | `dial_string` | ASCII | 40-byte null-padded, e.g. `"12345"` or phone number |
|
||||
| `[87]` | `after_event_recorded` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[91]` | `at_specified_times` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[93]` | `time1_enabled` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[101]` | `time1_hour` | uint8 | 0–23 |
|
||||
| `[102]` | `time1_min` | uint8 | 0–59 |
|
||||
| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[105]` | `time2_hour` | uint8 | 0–23 |
|
||||
| `[106]` | `time2_min` | uint8 | 0–59 |
|
||||
| `[117]` | DLE prefix `0x10` | — | Part of `\x10\x03` wire encoding for num_retries value 3 |
|
||||
| `[118]` | `num_retries` (value = 3) | uint8 | Logical value 0x03; check `raw[117] == 0x10` to detect DLE prefix |
|
||||
| `[120]` | `time_between_retries_sec` | uint8 | Shift +1 from logical 119 |
|
||||
| `[122]` | `wait_for_connection_sec` | uint8 | Shift +1 from logical 121 |
|
||||
| `[124]` | `warm_up_time_sec` | uint8 | Shift +1 from logical 123 |
|
||||
|
||||
**Unconfirmed fields** (offsets not yet mapped from captures):
|
||||
- Time slots 3 and 4 (if they exist — Blastware UI only shows 2 time slots in observed sessions)
|
||||
- `modem_power_relay_enabled` (bool)
|
||||
- `storage_mode` (call home trigger on all events vs. triggered only?)
|
||||
|
||||
#### 7.12.3 DLE-Escaped 0x03 — Critical Detail
|
||||
|
||||
The `\x10\x03` sequence at raw[117:119] is **not** a DLE stuffing artifact in the usual
|
||||
sense. Standard DLE stuffing escapes `\x10` → `\x10\x10`. But here the device is encoding
|
||||
the integer value `3` in a position where the byte `\x03` would be indistinguishable from
|
||||
the frame ETX terminator. The device therefore sends `\x10\x03` (DLE + ETX = "inner-frame
|
||||
terminator" in S3 inner-frame syntax). S3FrameParser correctly handles this: in
|
||||
`STATE_AFTER_DLE`, seeing `\x03` (ETX) while **inside** an outer frame causes it to
|
||||
append both `\x10` and `\x03` as literal bytes rather than ending the frame. The outer
|
||||
frame only terminates on a **bare** `\x03` (without the DLE prefix).
|
||||
|
||||
The write frame sends these bytes verbatim — the device accepts `\x10\x03` in the write
|
||||
payload and interprets it as the value 3. No transformation is needed in
|
||||
`_encode_call_home_config()`.
|
||||
|
||||
**Limitation:** Any field that needs to encode the value `3` (0x03) requires this DLE
|
||||
prefix. The current encoder raises `ValueError` if any hour or minute field equals 3,
|
||||
since the encoder does not yet implement DLE-prefixed writes for arbitrary field positions.
|
||||
In practice, 3:00 AM / 3 minutes past are unlikely scheduled call times.
|
||||
|
||||
#### 7.12.4 Write Protocol — SUB 0x7E → 0x7F
|
||||
|
||||
Write format (same as other write commands — only BW_CMD `0x10` doubled on wire;
|
||||
all other bytes written raw; DLE-aware checksum):
|
||||
|
||||
| Step | SUB | Payload | Offset | Response |
|
||||
|---|---|---|---|---|
|
||||
| Data write | `0x7E` | 127 bytes (125-byte read payload + `\x00\x00`) | `data[1]+2 = 0x7E` (126) | `0x81` |
|
||||
| Confirm | `0x7F` | empty | `0x00` | `0x80` |
|
||||
|
||||
**Write payload construction:**
|
||||
```python
|
||||
write_payload = bytearray(raw_125_bytes)
|
||||
write_payload.append(0x00)
|
||||
write_payload.append(0x00)
|
||||
# patch fields in-place, then pass bytes(write_payload) to build_bw_write_frame
|
||||
```
|
||||
|
||||
**Offset formula:** `write_payload[1] = 0x7C` (same as DATA_LENGTH).
|
||||
`offset = write_payload[1] + 2 = 0x7C + 2 = 0x7E = 126`.
|
||||
This follows the identical pattern as SUB 0x68 (event index write) and SUB 0x69 (waveform write).
|
||||
|
||||
**No preceding 0x2C read required** — Blastware sends SUB 0x7E directly using cached
|
||||
state. The `seismo-relay` implementation always reads first (`get_call_home_config()`)
|
||||
before writing for safety.
|
||||
|
||||
#### 7.12.5 Implementation Notes
|
||||
|
||||
- `MiniMateProtocol.read_call_home_config()` — standard two-step read; returns `data_rsp.data[11:]` (125 bytes raw)
|
||||
- `MiniMateProtocol.write_call_home_config(data)` — sends SUB 0x7E (127-byte payload) then SUB 0x7F confirm
|
||||
- `MiniMateClient.get_call_home_config()` → `CallHomeConfig` dataclass
|
||||
- `MiniMateClient.set_call_home_config(...)` — reads current config, patches via `_encode_call_home_config()`, writes back
|
||||
- `_decode_call_home_config(raw)` — handles DLE prefix detection at raw[117]
|
||||
- `_encode_call_home_config(raw, ...)` — patches in-place, appends 2 trailing zeros; raises `ValueError` if any hour/min == 3
|
||||
- REST API: `GET /device/call_home` and `POST /device/call_home` in `sfm/server.py`
|
||||
- Web UI: "Call Home" tab in `sfm/sfm_webapp.html`
|
||||
|
||||
---
|
||||
|
||||
## 8. Timestamp Format
|
||||
|
||||
Two timestamp wire formats are used:
|
||||
@@ -1933,7 +2137,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
|
||||
| **Auxiliary Trigger read location** — **RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 |
|
||||
| **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW |
|
||||
| ~~**SUB `6E` response to SUB `1C`**~~ — ~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF − SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED |
|
||||
| **Max Geo Range float 6.2061 in/s** — NOT a user-selectable range (manual only shows 1.25 and 10.0 in/s). Likely internal ADC full-scale constant or hardware range ceiling. Not worth capturing. | LOW | 2026-02-26 | Downgraded 2026-03-02 |
|
||||
| ~~**Max Geo Range float 6.2061**~~ — **RESOLVED 2026-04-17.** Confirmed as the **ADC-to-velocity scale factor** = inverse sensitivity = 1/0.161133 = **6.206053 (in/s)/V**. Source: Interface Handbook §4.5 formula `Range = 1.61133 V / Sensitivity`. For standard Instantel geo at Normal (Gain=1) range: Sensitivity = 1.61133/10 = 0.161133 V/(in/s), scale = 6.206053. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. The earlier ~9× overread was from using 6.206053 directly as range instead of as scale factor (range = 1.61133 V × 6.206053 = 10.000 in/s). Hardware constant — do NOT write. | RESOLVED | 2026-02-26 | Resolved 2026-04-17 |
|
||||
| MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | |
|
||||
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
|
||||
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
|
||||
@@ -1962,10 +2166,11 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
|
||||
| Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100–148 dB in 1 dB steps |
|
||||
| Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger |
|
||||
| Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1–105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. |
|
||||
| Max Geo Range | §3.8.4 | Channel block, float | float32 BE | 1.25 or 10.0 in/s (user); 6.2061 in protocol = internal constant |
|
||||
| ADC Scale Factor (geo_adc_scale) | §3.8.4 / Interface Handbook §4.5 | Channel block, Tran+28 (same in E5 read and SUB 71 write), float32 BE | float32 BE = 6.206053 | ✅ CONFIRMED 2026-04-17 — inverse sensitivity (in/s)/V. `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant, identical on all units. Do NOT write. |
|
||||
| Max Geo Range (geo_range) | §3.8.4 | Channel block, Tran+33 (same in E5 read and SUB 71 write), uint8; applied to Tran/Vert/Long | uint8 | ✅ CONFIRMED 2026-04-20 — `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. **NOTE: `Tran+20` reads `0x01` on ALL captures regardless of range — it is NOT this field.** |
|
||||
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
|
||||
| Sample Rate | §3.8.2 | cfg anchor−2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. |
|
||||
| Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo |
|
||||
| Record Mode | §3.8.1 | Write: `cfg[anchor−3]`, uint8. Read (E5 sf1): `data[anchor−4]`, uint8. Note: extra `0x10` byte at read `data[anchor−3]` shifts offset by 1 vs write. | uint8 | `0x00`=Single Shot, `0x01`=Continuous, `0x02`=unknown, `0x03`=Histogram, `0x04`=Histogram+Continuous. ✅ CONFIRMED 2026-04-20 |
|
||||
| Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. |
|
||||
| Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 1–9 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. |
|
||||
| Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 |
|
||||
@@ -2068,6 +2273,279 @@ Semantic Interpretation <- settings, events, responses
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Blastware Binary File Formats (.N00 / .MLG / others)
|
||||
|
||||
> ✅ CONFIRMED 2026-04-21 — all fields verified by binary diff of reconstructed vs reference
|
||||
> files from the 4-3-26-multi_event capture (M529LIY6.N00, BE11529.MLG).
|
||||
>
|
||||
> ⚠️ EXTENSION MAPPING REFUTED 2026-04-21 — earlier assumption that extension encodes
|
||||
> recording mode is **FALSE**. A continuous-mode event produced `.EI0`, not `.9T0`.
|
||||
> Extension encoding algorithm is unknown. Do not use extension to infer recording mode.
|
||||
|
||||
### D.1 Common File Header (22 bytes)
|
||||
|
||||
All Blastware files (regardless of type) share an 18-byte prefix followed by a 4-byte type tag.
|
||||
|
||||
| Offset | Length | Value | Description |
|
||||
|---|---|---|---|
|
||||
| 0x00 | 6 | `10 00 01 80 00 00` | Fixed prefix |
|
||||
| 0x06 | 10 | `Instantel\x00` | ASCII string |
|
||||
| 0x10 | 2 | `07 2c` | Fixed suffix |
|
||||
| 0x12 | 4 | varies | File type tag (see below) |
|
||||
|
||||
**Total header: 22 bytes.**
|
||||
|
||||
**Type tags:**
|
||||
|
||||
| Extension | Type tag | Description |
|
||||
|---|---|---|
|
||||
| `.N00` | `00 12 03 00` | Waveform event (confirmed) |
|
||||
| `.9T0` | `00 12 03 00` | Waveform event — same type tag as .N00 (assumed; not independently confirmed) |
|
||||
| `.EI0` | `00 12 03 00` | Waveform event — same type tag (assumed; continuous-mode event observed 2026-04-21) |
|
||||
| `.MLG` | `22 01 0e a0` | Monitor log |
|
||||
|
||||
**Extension encoding — new firmware (V10.72+) FULLY DECODED (confirmed 2026-04-22):**
|
||||
|
||||
The extension differs depending on how the file was saved:
|
||||
|
||||
| Download method | Extension format | Example |
|
||||
|---|---|---|
|
||||
| Manual / direct (Blastware connected to unit) | `AB0` (3 chars) | `.CE0` |
|
||||
| Call-home / ACH | `AB0W` or `AB0H` (4 chars) | `.CE0H` |
|
||||
|
||||
Where:
|
||||
- `AB` = 2-char base-36 of `total_seconds % 1296`; `A = value // 36`, `B = value % 36`
|
||||
- `total_seconds = (event_local_time − 1985-01-01T00:00:00_local)` in seconds
|
||||
- `0` = always literal digit zero
|
||||
- `W` = Full Waveform, `H` = Full Histogram (ACH only)
|
||||
|
||||
Base-36 alphabet: `0–9` = 0–9, `A–Z` = 10–35.
|
||||
|
||||
The 10-year production archive contains only ACH files (all end in W or H). Manual Blastware downloads produce the same `AB0` prefix but without the trailing type character.
|
||||
|
||||
**3-day cycle property (confirmed 2026-04-22):** A unit recording at a fixed daily time cycles through exactly **3 different extensions** with a 3-day period. Each calendar day shifts `total_seconds % 1296` by 864 (since `86400 % 1296 = 864`). The cycle repeats every 3 days because `gcd(1296, 864) = 432`. Confirmed from archive: top 3 extensions `CE0H` (95), `0E0H` (93), `OE0H` (91) are the 3-day cycle of a 06:00:14 daily call-in (seconds-in-window = 446, 14, 878).
|
||||
|
||||
**B character invariance:** `864 = 24 × 36`, so adding one day never changes `value % 36` — the second extension character is invariant for a fixed daily recording time. Only the first character cycles through 3 values.
|
||||
|
||||
**Old firmware (S338):** 3-char extensions observed (`.N00`, `.EI0`, etc.) — may simply be manual downloads under the same AB0 scheme, or a different encoding. Not yet confirmed.
|
||||
|
||||
**Micromate Series 4** uses a different extension format (observed: `IDFH`, `IDFW`). This formula does NOT apply to Micromate units.
|
||||
|
||||
All waveform files share the same `00 12 03 00` type tag regardless of extension. Blastware identifies file type by extension, not by type tag alone.
|
||||
|
||||
### D.2 Timestamp Encoding (Blastware files)
|
||||
|
||||
All timestamps in N00 and MLG files use an **8-byte big-endian format**:
|
||||
|
||||
| Byte | Field |
|
||||
|---|---|
|
||||
| 0 | day (uint8) |
|
||||
| 1 | month (uint8) |
|
||||
| 2–3 | year (uint16 BE) |
|
||||
| 4 | `0x00` (reserved) |
|
||||
| 5 | hour (uint8) |
|
||||
| 6 | minute (uint8) |
|
||||
| 7 | second (uint8) |
|
||||
|
||||
Example: `01 04 07 ea 00 00 1c 08` → April 1, 2026, 00:28:08.
|
||||
|
||||
Note: this differs from the 8-byte protocol timestamp (`[day][sub_code][month][year_HI][year_LO][0x00][hour][min][sec]` = 9 bytes) used in the device's on-wire 0C waveform records. The file format uses a compact 8-byte layout without the `sub_code` byte.
|
||||
|
||||
### D.3 N00 File Format — Single-Shot Waveform Event
|
||||
|
||||
**File layout:** `[22B header] [21B STRT record] [body bytes] [26B footer]`
|
||||
|
||||
#### D.3.1 STRT Record (21 bytes)
|
||||
|
||||
The STRT record immediately follows the 22-byte header.
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 4 | `STRT` | ASCII literal |
|
||||
| 4 | 2 | `ff fe` | Fixed |
|
||||
| 6 | 4 | event key (key4) | 4-byte waveform key |
|
||||
| 10 | 4 | device-specific | NOT a repeat of key4 — device-internal field |
|
||||
| 14 | 6 | device-specific | NOT zero-padded — device-internal fields |
|
||||
| 20 | 1 | rectime | uint8 seconds |
|
||||
|
||||
**Critical:** The STRT record must be copied verbatim from A5[0].data[7+strt_pos:] — bytes [10:20] contain device-specific values that cannot be reconstructed from protocol-level Event fields alone.
|
||||
|
||||
#### D.3.2 Body Bytes (variable)
|
||||
|
||||
The body is reconstructed from the raw A5 bulk waveform stream frames by stripping DLE framing markers and taking the appropriate slice of each frame's data section.
|
||||
|
||||
**Per-frame contribution (from `frame.data`):**
|
||||
|
||||
| Frame | Skip amount | Notes |
|
||||
|---|---|---|
|
||||
| A5[0] (probe) | `7 + strt_pos_in_w0 + 21` | Skip frame.data prefix + STRT record |
|
||||
| A5[1] | 13 | 7-byte prefix + 6-byte first-chunk header |
|
||||
| A5[2..N] | 12 | 7-byte prefix + 5-byte chunk header |
|
||||
| Terminator (page_key=0x0000) | 11 | 7-byte prefix + 4-byte terminator header |
|
||||
|
||||
**DLE strip rule:** For each frame's contribution (`frame.data[skip:]`), strip any `0x10` byte immediately followed by `0x02`, `0x03`, or `0x04`. Only the `0x10` is stripped; the following byte is kept as payload.
|
||||
|
||||
**Split-pair edge case:** When `frame.data[-1] == 0x10` AND `frame.chk_byte ∈ {0x02, 0x03, 0x04}`, the S3FrameParser split a DLE+XX pair at the payload/checksum boundary. Reunite the bytes before stripping (`relevant + bytes([chk_byte])`), then always remove the trailing chk_byte from the result (`stripped[:-1]`) — chk_byte is the wire checksum, never payload.
|
||||
|
||||
**Body/footer split:** Accumulate all frame contributions (data frames + terminator) into `all_bytes`. Then:
|
||||
- `body = all_bytes[:-26]` (variable length)
|
||||
- `footer = all_bytes[-26:]` (always 26 bytes — extracted from terminator content)
|
||||
|
||||
#### D.3.3 Footer (26 bytes)
|
||||
|
||||
The footer terminates the N00 file. Its bytes come directly from the terminator A5 frame's inner content — do NOT reconstruct from event metadata.
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | `0e 08` | Fixed marker |
|
||||
| 2 | 8 | ts1 | Start timestamp (8B big-endian) |
|
||||
| 10 | 8 | ts2 | Stop timestamp (8B big-endian) |
|
||||
| 18 | 6 | `00 01 00 02 00 00` | Fixed |
|
||||
| 24 | 2 | CRC | 2-byte CRC — algorithm unconfirmed |
|
||||
|
||||
**CRC:** The 2-byte CRC at footer[24:26] has an unconfirmed algorithm. In M529LIY6.N00 it reads `fe da`. Attempts to match CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), and 40+ polynomial/init combinations all failed. The writer copies it verbatim from the terminator frame.
|
||||
|
||||
### D.4 MLG File Format — Monitor Log
|
||||
|
||||
**File layout:** `[308B header] [N × 292B records]`
|
||||
|
||||
#### D.4.1 MLG Header (308 bytes)
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0x00 | 22 | common header | prefix + `22 01 0e a0` type tag |
|
||||
| 0x16 | 16 | unknown | observed as zeros in BE11529.MLG |
|
||||
| 0x2A | 8 | serial number | null-padded ASCII (e.g. `"BE11529"`) |
|
||||
| 0x32 | remainder | zero pad | pads to 308 bytes total |
|
||||
|
||||
#### D.4.2 MLG Record (292 bytes each)
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | CRC | 2-byte CRC — algorithm unconfirmed; write as `00 00` |
|
||||
| 2 | 4 | `22 01 0e 80` | Record marker |
|
||||
| 6 | 8 | ts1 | Start timestamp (8B big-endian) |
|
||||
| 14 | 8 | ts2 | Stop timestamp (8B big-endian); zeros if no stop |
|
||||
| 22 | 4 | flags | Record type flags (see below) |
|
||||
| 26 | 10 | serial | Null-padded ASCII serial number |
|
||||
| 36 | variable | text | Type-dependent content |
|
||||
| — | remainder | zero pad | pads to 292 bytes total |
|
||||
|
||||
**Record flags:**
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `ff ff 00 00` | Monitoring start with no stop recorded |
|
||||
| `01 00 02 00` | Triggered event (has ts1 + ts2) |
|
||||
| `02 00 00 00` | Monitoring interval (has ts1 + ts2) |
|
||||
|
||||
**Text content for triggered events (`flags = 01 00 02 00`):**
|
||||
|
||||
| Byte | Field |
|
||||
|---|---|
|
||||
| 0 | `0x08` |
|
||||
| 1–8 | ts1 copy (8B big-endian) |
|
||||
| 9+ | `"Geo: X.XXX in/s\x00"` ASCII geo threshold |
|
||||
|
||||
#### D.4.3 MLG CRC
|
||||
|
||||
The 2-byte CRC at record[0:2] uses an unconfirmed algorithm. Tested against CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, and 40+ polynomial/init combinations — none matched. The writer emits `00 00`. Blastware may reject files with incorrect CRCs (impact on import unknown — TODO: test).
|
||||
|
||||
### D.5 Filename Encoding ✅ PARTIALLY CONFIRMED 2026-04-22
|
||||
|
||||
Blastware assigns waveform filenames of the form `<prefix_letter><serial3><stem><ext>`, where:
|
||||
|
||||
#### D.5.1 Serial Prefix ✅ CONFIRMED 2026-04-22
|
||||
|
||||
The first 4 characters of the filename encode the full device serial number:
|
||||
|
||||
```
|
||||
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
|
||||
serial3 = f"{serial_numeric % 1000:03d}" (last 3 digits, zero-padded)
|
||||
```
|
||||
|
||||
Where `serial_numeric` is the integer after the "BE" device-type prefix.
|
||||
|
||||
Examples (all confirmed from archive):
|
||||
|
||||
| Serial | serial_numeric / 1000 | prefix_letter | serial3 | Filename prefix |
|
||||
|--------|----------------------|---------------|---------|-----------------|
|
||||
| BE6907 | 6 | H | 907 | H907 |
|
||||
| BE7145 | 7 | I | 145 | I145 |
|
||||
| BE11529 | 11 | M | 529 | M529 |
|
||||
| BE14036 | 14 | P | 036 | P036 |
|
||||
| BE17353 | 17 | S | 353 | S353 |
|
||||
| BE18003 | 18 | T | 003 | T003 |
|
||||
| BE18191 | 18 | T | 191 | T191 |
|
||||
| BE18676 | 18 | T | 676 | T676 |
|
||||
|
||||
**Interpretation:** The prefix letter encodes the production generation (batch of 1000 units). B=generation 0 (serials 0–999), C=generation 1 (1000–1999), etc. No units with prefix A have been observed — the earliest known units start around serial 2000+ (prefix D).
|
||||
|
||||
**Note:** The "BE" device-type prefix is implicit. The filename only encodes the numeric part of the serial. Other Instantel device types (Micromate, Blastmate) may use a different scheme.
|
||||
|
||||
#### D.5.2 Stem + Extension — full timestamp encoding ✅ FULLY CONFIRMED 2026-04-22
|
||||
|
||||
The stem (4 chars) and AB extension (2 chars) together form a 6-digit base-36 number encoding a complete second-resolution timestamp:
|
||||
|
||||
```python
|
||||
total_seconds = stem_int * 1296 + ab_int
|
||||
event_local_time = datetime(1985, 1, 1) + timedelta(seconds=total_seconds)
|
||||
```
|
||||
|
||||
- **Epoch:** `1985-01-01 00:00:00` **device local time** ✅ CONFIRMED — verified against 3,248 files from a 10-year production archive; zero errors (only 2 mismatches were Micromate `IDFH`/`IDFW` files which use a completely different naming scheme)
|
||||
- **Unit:** 1296 seconds = 36² ≈ 21.6 minutes per stem increment
|
||||
- **Alphabet:** `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (digits then uppercase letters)
|
||||
- **Collision:** Events within the same 21.6-minute window share a stem; extension distinguishes them
|
||||
|
||||
**Decoding example — `P036L318.C80H` (BE14036, Full Histogram):**
|
||||
```
|
||||
stem L318 = 21×36³ + 3×36² + 1×36 + 8 = 983,708
|
||||
AB C8 = 12×36 + 8 = 440
|
||||
total_sec = 983,708 × 1296 + 440 = 1,274,886,008
|
||||
event_time = 1985-01-01 + 1,274,886,008s = 2025-05-26 15:00:08 local
|
||||
```
|
||||
|
||||
**Note on local time:** The device's onboard clock is set to the local timezone of the deployment site. The epoch and all timestamps are in that same local time — there is no UTC conversion. Files moved between timezones will decode to the original deployment timezone.
|
||||
|
||||
#### D.5.3 Extension taxonomy
|
||||
|
||||
Third character of extension is always `'0'`. File type is identified by extension, not by the type tag in the header (all waveform extensions share type tag `00 12 03 00`).
|
||||
|
||||
| Extension | Recording mode | Sample rate | Status |
|
||||
|---|---|---|---|
|
||||
| `.N00` | Single Shot (0x00) | 1024 sps | ✅ CONFIRMED |
|
||||
| `.9T0` | Continuous (0x01) | 1024 sps | ✅ CONFIRMED |
|
||||
| `.490` | ? | ? | ❓ observed from M529LJ8V.490 |
|
||||
| `.5K0` | ? | ? | ❓ observed from M529LJDY.5K0 |
|
||||
| `.980` | ? | ? | ❓ observed from M529LJDY.980 |
|
||||
| `.ML0` | ? | ? | ❓ observed from M529LJDY.ML0 (167s duration; possibly Histogram) |
|
||||
|
||||
**Why 5 extensions for "Continuous"?** Binary analysis of all 6 example files shows that `.9T0`, `.490`, `.5K0`, `.980`, `.ML0` are byte-for-byte identical in all metadata regions (compliance anchor block, channel descriptor blocks `Tran/Vert/Long/MicL`). The A5 frame 7 body reflects the **session-start** compliance config, not the per-event capture config. All 5 files show recording_mode=0x01 and sample_rate=1024 in the body. The extension must therefore encode the **capture-time** compliance state — likely a combination of recording mode, sample rate, and possibly mic units or other options. This cannot be determined from file body alone without capture-time compliance data from the 0C record sub_code and the actual waveform sample count.
|
||||
|
||||
**DLE-shift offset note for reading recording_mode from N00/9T0 body:**
|
||||
|
||||
The compliance block in the file body has been through `_strip_inner_frame_dles`. The 0x10 constant at logical `anchor−7` (between recording_mode and sample_rate_HI) gets stripped when sample_rate_HI = `0x04` (1024 sps), because `0x10` precedes `0x04 ∈ {0x02,0x03,0x04}`. After stripping, the anchor shifts left by 1, so:
|
||||
|
||||
| 1024 sps (strip occurs) | 2048 or 4096 sps (no strip) |
|
||||
|---|---|
|
||||
| `file[anc−7]` = recording_mode | `file[anc−8]` = recording_mode |
|
||||
| `file[anc−6:anc−4]` = sample_rate | `file[anc−6:anc−4]` = sample_rate |
|
||||
|
||||
For 1024 sps files, the expected file bytes around the anchor are:
|
||||
```
|
||||
file[anc−9]: mode_prefix (0x00 for Single Shot/Continuous; 0x10 for Histogram)
|
||||
file[anc−8]: 0x00 (was recording_mode, but shifted away — now reads 0x00 for mode_prefix)
|
||||
file[anc−7]: recording_mode (0x00=Single Shot, 0x01=Continuous, etc.)
|
||||
file[anc−6]: 0x04 (sample_rate_HI for 1024 sps)
|
||||
file[anc−5]: 0x00 (sample_rate_LO)
|
||||
file[anc−4]: histogram_interval_HI
|
||||
file[anc−3]: histogram_interval_LO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*All findings reverse-engineered from live RS-232 bridge captures.*
|
||||
*Cross-referenced from 2026-03-02 with Instantel MiniMate Plus Operator Manual (716U0101 Rev 15).*
|
||||
*This is a living document — append changelog entries and timestamps as new findings are confirmed or corrected.*
|
||||
@@ -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")
|
||||
+564
-258
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -457,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:
|
||||
@@ -592,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,
|
||||
)
|
||||
|
||||
+101
-6
@@ -269,7 +269,7 @@ class ChannelConfig:
|
||||
label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅
|
||||
trigger_level: float # in/s (geo) or psi (MicL) ✅
|
||||
alarm_level: float # in/s (geo) or psi (MicL) ✅
|
||||
max_range: float # 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" ✅
|
||||
|
||||
|
||||
@@ -338,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,
|
||||
@@ -359,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
|
||||
@@ -402,6 +493,10 @@ class Event:
|
||||
# Set by get_events(); required by download_waveform().
|
||||
_waveform_key: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
# Raw A5 frames from the full bulk waveform download (full_waveform=True).
|
||||
# Populated by get_events() when full_waveform=True; used by write_blastware_file().
|
||||
_a5_frames: Optional[list] = field(default=None, repr=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
ts = str(self.timestamp) if self.timestamp else "no timestamp"
|
||||
ppv = ""
|
||||
|
||||
+263
-34
@@ -65,6 +65,7 @@ SUB_WAVEFORM_HEADER = 0x0A
|
||||
SUB_WAVEFORM_RECORD = 0x0C
|
||||
SUB_BULK_WAVEFORM = 0x5A
|
||||
SUB_COMPLIANCE = 0x1A
|
||||
SUB_CALL_HOME = 0x2C # Call home config read → response 0xD3 ✅
|
||||
SUB_UNKNOWN_2E = 0x2E
|
||||
|
||||
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
|
||||
@@ -78,6 +79,10 @@ SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅
|
||||
SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅
|
||||
SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅
|
||||
|
||||
# Call home write SUBs (confirmed from 4-20-26 call home settings captures)
|
||||
SUB_CALL_HOME_WRITE = 0x7E # Write call home config → response 0x81 ✅
|
||||
SUB_CALL_HOME_CONFIRM = 0x7F # Confirm call home write → response 0x80 ✅
|
||||
|
||||
# Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture)
|
||||
SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅
|
||||
SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅
|
||||
@@ -109,6 +114,7 @@ DATA_LENGTHS: dict[int, int] = {
|
||||
# SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response
|
||||
# data[4]. Do NOT add it here; use read_waveform_header() instead. ✅
|
||||
SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅
|
||||
SUB_CALL_HOME: 0x7C, # 124-byte call home config ✅ (confirmed 4-20-26)
|
||||
SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶
|
||||
0x09: 0xCA, # 202 bytes, purpose TBD 🔶
|
||||
# SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total;
|
||||
@@ -120,10 +126,12 @@ DATA_LENGTHS: dict[int, int] = {
|
||||
_BULK_CHUNK_OFFSET = 0x1004 # offset field for probe + all regular chunk requests ✅
|
||||
_BULK_TERM_OFFSET = 0x005A # offset field for termination request ✅
|
||||
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
|
||||
# Chunk counter formula: chunk_num * 0x0400 for ALL chunks including chunk 1.
|
||||
# Earlier captures showed 0x1004 for chunk 1 — that was a Blastware artifact, not a
|
||||
# protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004
|
||||
# causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks.
|
||||
# Chunk counter formula: key4[2:4] + (chunk_num - 1) * 0x0400
|
||||
# where key4[2:4] is the event's circular-buffer base offset ((key4[2]<<8)|key4[3]).
|
||||
# Earlier captures showed 0x1004 for chunk 1 of key 01110000 — that was a Blastware
|
||||
# artifact. For keys where key4[2:4] != 0x0000 (e.g. key 01111884) the old
|
||||
# "n * 0x0400" formula sends counters from the wrong buffer region and the device
|
||||
# returns data from a different event. Confirmed correct 2026-04-24.
|
||||
|
||||
# Default timeout values (seconds).
|
||||
# MiniMate Plus is a slow device — keep these generous.
|
||||
@@ -520,7 +528,9 @@ class MiniMateProtocol:
|
||||
*,
|
||||
stop_after_metadata: bool = True,
|
||||
max_chunks: int = 32,
|
||||
) -> list[bytes]:
|
||||
include_terminator: bool = False,
|
||||
extra_chunks_after_metadata: int = 1,
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event.
|
||||
|
||||
@@ -536,7 +546,9 @@ class MiniMateProtocol:
|
||||
4. Termination (offset=_BULK_TERM_OFFSET, counter=last+_BULK_COUNTER_STEP)
|
||||
Device responds with a final A5 frame (page_key=0x0000).
|
||||
|
||||
The termination frame (page_key=0x0000) is NOT included in the returned list.
|
||||
By default the termination frame (page_key=0x0000) is NOT included in the
|
||||
returned list. Pass include_terminator=True to append it; the blastware_file
|
||||
writer needs the terminator frame's body to reconstruct the waveform file footer.
|
||||
|
||||
Args:
|
||||
key4: 4-byte waveform key from EVENT_HEADER (1E).
|
||||
@@ -546,11 +558,16 @@ class MiniMateProtocol:
|
||||
hundred KB). Set False to download everything.
|
||||
max_chunks: Safety cap on the number of chunk requests sent
|
||||
(default 32; a typical event uses 9 large frames).
|
||||
include_terminator: If True, append the terminator A5 frame
|
||||
(page_key=0x0000) to the returned list. The
|
||||
terminator carries the waveform file footer bytes.
|
||||
Default False preserves existing caller behaviour.
|
||||
|
||||
Returns:
|
||||
List of raw data bytes from each A5 response frame (not including
|
||||
the terminator frame). Frame indices match the request sequence:
|
||||
index 0 = probe response, index 1 = first chunk, etc.
|
||||
List of S3Frame objects from each A5 response frame. Frame indices
|
||||
match the request sequence: index 0 = probe response, index 1 = first
|
||||
chunk, etc. If include_terminator=True, the last element is the
|
||||
terminator frame (page_key=0x0000).
|
||||
|
||||
Raises:
|
||||
ProtocolError: on timeout, bad checksum, or unexpected SUB.
|
||||
@@ -565,16 +582,24 @@ class MiniMateProtocol:
|
||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||
|
||||
rsp_sub = _expected_rsp_sub(SUB_BULK_WAVEFORM) # 0xFF - 0x5A = 0xA5
|
||||
frames_data: list[bytes] = []
|
||||
frames_data: list[S3Frame] = []
|
||||
counter = 0
|
||||
|
||||
# BW counter formula (confirmed from 4-3-26 capture for key 0111245a,
|
||||
# and empirical live-device test 2026-04-06 for key 01110000):
|
||||
# counter for chunk n = max(key4[2:4], 0x0400) + (n - 1) * 0x0400
|
||||
# key4[2:4] is the event's circular-buffer base offset. The max() guard
|
||||
# ensures chunk 1 never uses counter=0x0000 (which equals the probe address
|
||||
# and causes the device to re-return STRT record data for the first chunk).
|
||||
_key4_offset = (key4[2] << 8) | key4[3]
|
||||
|
||||
# ── Step 1: probe ────────────────────────────────────────────────────
|
||||
log.debug("5A probe key=%s", key4.hex())
|
||||
log.debug("5A probe key=%s key4_offset=0x%04X", key4.hex(), _key4_offset)
|
||||
params = bulk_waveform_params(key4, 0, is_probe=True)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed counter before probe recv
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False)
|
||||
probe_batch = self._recv_5a_batch(rsp_sub)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"5A probe TIMED OUT for key=%s — "
|
||||
@@ -582,23 +607,54 @@ class MiniMateProtocol:
|
||||
key4.hex(), self._parser.bytes_fed,
|
||||
)
|
||||
raise
|
||||
frames_data.append(rsp.data)
|
||||
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
|
||||
frames_data.extend(probe_batch)
|
||||
log.debug(
|
||||
"5A probe: %d frame(s) page_keys=%s",
|
||||
len(probe_batch),
|
||||
[f"0x{f.page_key:04X}" for f in probe_batch],
|
||||
)
|
||||
|
||||
# Log probe frame size for diagnostics.
|
||||
# The device always needs extra_chunks_after_metadata chunks after the
|
||||
# metadata frame before termination to prime the valid waveform footer.
|
||||
# This holds regardless of TCP frame size (1-frame vs 2-frame mode).
|
||||
_effective_extra_chunks = extra_chunks_after_metadata
|
||||
log.warning(
|
||||
"5A probe data_len=%d effective_extra_chunks=%d",
|
||||
len(probe_batch[0].data),
|
||||
_effective_extra_chunks,
|
||||
)
|
||||
|
||||
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
||||
# Chunk counters are monotonic: chunk_num * 0x0400 for all chunks.
|
||||
# The 4-2-26 BW TX capture showed 0x1004 for chunk 1, but this is a
|
||||
# Blastware artifact — the device accepts any counter value and streams
|
||||
# data regardless. Empirically confirmed 2026-04-06: 0x0400 for chunk 1
|
||||
# works; 0x1004 causes the device to ignore the frame (timeout).
|
||||
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
||||
# where _chunk_base = max(key4[2:4], 0x0400).
|
||||
#
|
||||
# For events with key4[2:4] != 0 (e.g. key 0111245a, offset 0x245a):
|
||||
# _chunk_base = 0x245a → chunk 1=0x245a, chunk 2=0x285a, ...
|
||||
# Confirmed from 4-3-26 capture.
|
||||
#
|
||||
# For events with key4[2:4] == 0 (e.g. key 01110000):
|
||||
# _chunk_base = max(0, 0x0400) = 0x0400
|
||||
# → chunk 1=0x0400, chunk 2=0x0800, ... (= old chunk_num*0x0400)
|
||||
# CRITICAL: counter=0x0000 (same as the probe) causes the device to
|
||||
# re-return the STRT record data for chunk 1, making frame 1 look like
|
||||
# a second probe response (confirmed from server log: frame 1 len=1097,
|
||||
# contains STRT\xff\xfe, contributes zero body bytes after DLE-strip).
|
||||
# counter=0x0400 for chunk 1 confirmed working (empirical test 2026-04-06).
|
||||
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
|
||||
for chunk_num in range(1, max_chunks + 1):
|
||||
counter = chunk_num * _BULK_COUNTER_STEP
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed for accurate per-chunk count
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False, timeout=10.0)
|
||||
# Collect ALL frames from this chunk response.
|
||||
# Over TCP via modem, a single large A5 device response (~1100 bytes
|
||||
# RS-232) is split across ~2 TCP segments, each parsed as its own
|
||||
# complete S3 frame. _recv_5a_batch gathers all of them so that
|
||||
# every subsequent chunk request is paired with the correct response.
|
||||
batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
except TimeoutError:
|
||||
raw = self._parser.bytes_fed
|
||||
log.warning(
|
||||
@@ -617,20 +673,51 @@ class MiniMateProtocol:
|
||||
break
|
||||
raise
|
||||
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
# Process all frames from this batch.
|
||||
metadata_found = False
|
||||
for rsp in batch:
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream.
|
||||
log.debug("5A page_key=0x0000 — device terminated early")
|
||||
if include_terminator:
|
||||
frames_data.append(rsp)
|
||||
return frames_data
|
||||
frames_data.append(rsp)
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
metadata_found = True
|
||||
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream (no termination needed).
|
||||
log.debug("5A A5[%d] page_key=0x0000 — device terminated early", chunk_num)
|
||||
return frames_data
|
||||
|
||||
frames_data.append(rsp.data)
|
||||
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
log.debug("5A A5[%d] metadata found — stopping early", chunk_num)
|
||||
if metadata_found:
|
||||
# Download extra_chunks_after_metadata more chunks after metadata.
|
||||
# This primes the device to return the valid waveform footer in the
|
||||
# termination response — without it the terminator carries too few bytes
|
||||
# (confirmed 2026-04-23). The extra chunk data also belongs in the
|
||||
# file body (confirmed from TCP capture analysis 2026-04-27).
|
||||
log.debug("5A metadata found — fetching %d more chunk(s)",
|
||||
_effective_extra_chunks)
|
||||
for _extra_n in range(_effective_extra_chunks):
|
||||
chunk_num += 1
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
try:
|
||||
extra_batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
for ef in extra_batch:
|
||||
log.debug(
|
||||
"5A extra chunk page_key=0x%04X data_len=%d",
|
||||
ef.page_key, len(ef.data),
|
||||
)
|
||||
if ef.page_key == 0x0000:
|
||||
if include_terminator:
|
||||
frames_data.append(ef)
|
||||
return frames_data
|
||||
frames_data.append(ef)
|
||||
except TimeoutError:
|
||||
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
|
||||
break
|
||||
break
|
||||
else:
|
||||
log.warning(
|
||||
@@ -652,6 +739,8 @@ class MiniMateProtocol:
|
||||
"5A termination response page_key=0x%04X %d bytes",
|
||||
term_rsp.page_key, len(term_rsp.data),
|
||||
)
|
||||
if include_terminator:
|
||||
frames_data.append(term_rsp)
|
||||
except TimeoutError:
|
||||
log.debug("5A no termination response — device may have already closed")
|
||||
|
||||
@@ -1087,6 +1176,89 @@ class MiniMateProtocol:
|
||||
self._send(frame)
|
||||
return self.recv_write_ack(expected_sub=rsp_sub)
|
||||
|
||||
# ── Call home config (SUBs 0x2C / 0x7E / 0x7F) ──────────────────────────
|
||||
|
||||
def read_call_home_config(self) -> bytes:
|
||||
"""
|
||||
Read the auto call home configuration (SUB 0x2C → response 0xD3).
|
||||
|
||||
Standard two-step read: probe (offset=0x00) then data (offset=0x7C=124).
|
||||
Returns the raw 125-byte payload (data[11:] of the data response).
|
||||
|
||||
Confirmed from 4-20-26 call home settings capture:
|
||||
- Probe response: data[4]=0x7C (confirms data length = 124)
|
||||
- Data response: 136 bytes total (11-byte echo header + 125 bytes payload)
|
||||
- Payload[0:3] = 0x00 0x7C 0xDC (header: zero, inner-length, constant)
|
||||
- Payload[5] = auto_call_home_enabled
|
||||
- Payload[6:46] = dial_string (40-byte null-padded ASCII "RADIO RING")
|
||||
|
||||
Returns:
|
||||
Raw 125-byte call home config payload (data[11:]).
|
||||
Suitable for round-trip write (append \\x00\\x00 → 127-byte write payload).
|
||||
|
||||
Raises:
|
||||
ProtocolError: on timeout or wrong response SUB.
|
||||
"""
|
||||
rsp_sub = _expected_rsp_sub(SUB_CALL_HOME) # 0xFF - 0x2C = 0xD3
|
||||
length = DATA_LENGTHS[SUB_CALL_HOME] # 0x7C = 124
|
||||
|
||||
log.debug("read_call_home_config: 0x2C probe")
|
||||
self._send(build_bw_frame(SUB_CALL_HOME, 0))
|
||||
self._recv_one(expected_sub=rsp_sub)
|
||||
|
||||
log.debug("read_call_home_config: 0x2C data request offset=0x%02X", length)
|
||||
self._send(build_bw_frame(SUB_CALL_HOME, length))
|
||||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||
|
||||
payload = data_rsp.data[11:]
|
||||
log.debug("read_call_home_config: received %d payload bytes", len(payload))
|
||||
return payload
|
||||
|
||||
def write_call_home_config(self, data: bytes) -> None:
|
||||
"""
|
||||
Write the auto call home configuration (SUB 0x7E → 0x7F confirm).
|
||||
|
||||
Write sequence (confirmed from 4-20-26 call home settings captures):
|
||||
SUB 0x7E write 127-byte payload → device acks SUB 0x81
|
||||
SUB 0x7F confirm (no data) → device acks SUB 0x80
|
||||
|
||||
The 127-byte write payload = 125-byte read payload + b'\\x00\\x00'.
|
||||
The offset field = data[1] + 2 = 0x7C + 2 = 0x7E = 126.
|
||||
|
||||
Write frame format: build_bw_write_frame (minimal DLE stuffing — only
|
||||
BW_CMD is doubled; all other bytes are RAW). The \\x10\\x03 sequence
|
||||
within the payload is preserved as-is (device interprets DLE+ETX as the
|
||||
literal value 0x03 per the inner-frame terminator convention).
|
||||
|
||||
Args:
|
||||
data: 127-byte write payload (read payload + \\x00\\x00 footer).
|
||||
Must start with [0x00][0x7C][...] (standard header).
|
||||
|
||||
Raises:
|
||||
ValueError: if data is not exactly 127 bytes or lacks expected header.
|
||||
ProtocolError: on timeout or wrong response SUB.
|
||||
"""
|
||||
if len(data) < 2:
|
||||
raise ValueError(f"call home write payload must be at least 2 bytes, got {len(data)}")
|
||||
rsp_sub_write = _expected_rsp_sub(SUB_CALL_HOME_WRITE) # 0xFF - 0x7E = 0x81
|
||||
rsp_sub_confirm = _expected_rsp_sub(SUB_CALL_HOME_CONFIRM) # 0xFF - 0x7F = 0x80
|
||||
|
||||
# Offset formula: data[1] + 2 (same pattern as other single-chunk writes)
|
||||
offset = data[1] + 2 # 0x7C + 2 = 0x7E = 126
|
||||
frame = build_bw_write_frame(SUB_CALL_HOME_WRITE, data, offset=offset)
|
||||
log.debug(
|
||||
"write_call_home_config: %d bytes data[1]=0x%02X offset=0x%04X",
|
||||
len(data), data[1], offset,
|
||||
)
|
||||
self._send(frame)
|
||||
self.recv_write_ack(expected_sub=rsp_sub_write)
|
||||
log.debug("write_call_home_config: write acked; sending confirm 0x7F")
|
||||
|
||||
confirm_frame = build_bw_write_frame(SUB_CALL_HOME_CONFIRM, b"")
|
||||
self._send(confirm_frame)
|
||||
self.recv_write_ack(expected_sub=rsp_sub_confirm)
|
||||
log.debug("write_call_home_config: confirm acked — done")
|
||||
|
||||
# ── Monitoring ────────────────────────────────────────────────────────────
|
||||
|
||||
def read_monitor_status(self) -> S3Frame:
|
||||
@@ -1231,6 +1403,63 @@ class MiniMateProtocol:
|
||||
log.debug("TX %d bytes: %s", len(frame), frame.hex())
|
||||
self._transport.write(frame)
|
||||
|
||||
def _recv_5a_batch(
|
||||
self,
|
||||
expected_sub: int,
|
||||
first_timeout: float = 10.0,
|
||||
batch_timeout: float = 0.5,
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Collect all S3 frames that arrive as part of one device response.
|
||||
|
||||
Over TCP via cellular modem, a single device A5 response (~1100 bytes of
|
||||
RS-232 data) is forwarded in multiple TCP segments due to the modem's
|
||||
data-forwarding timeout (~100-150 ms per segment). Each TCP segment
|
||||
contains a complete, valid S3 frame (~550 bytes). Calling _recv_one()
|
||||
once returns only the first segment's frame and misses the rest, causing
|
||||
the chunk request/response pairing to cascade out of alignment.
|
||||
|
||||
This helper collects ALL frames before returning, by trying additional
|
||||
short-timeout receives after the first frame arrives.
|
||||
|
||||
The caller must call self._parser.reset() before this method to ensure
|
||||
bytes_fed is accurate; this method always uses reset_parser=False.
|
||||
|
||||
Args:
|
||||
expected_sub: Expected SUB byte for validation.
|
||||
first_timeout: Timeout for the mandatory first frame. Should be
|
||||
generous (default 10 s) since the device may be slow.
|
||||
batch_timeout: Short timeout for subsequent frames. Default 0.5 s
|
||||
— comfortably longer than the modem forwarding gap
|
||||
(~150 ms) but short enough to avoid stalling when
|
||||
only one frame is expected (probe, terminator).
|
||||
|
||||
Returns:
|
||||
List of S3Frame objects in arrival order (at least one).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If no frame arrives within first_timeout.
|
||||
UnexpectedResponse: If any frame has the wrong SUB byte.
|
||||
"""
|
||||
frames: list[S3Frame] = []
|
||||
first = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=first_timeout,
|
||||
)
|
||||
frames.append(first)
|
||||
while True:
|
||||
try:
|
||||
extra = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=batch_timeout,
|
||||
)
|
||||
frames.append(extra)
|
||||
except TimeoutError:
|
||||
break
|
||||
return frames
|
||||
|
||||
def _recv_one(
|
||||
self,
|
||||
expected_sub: Optional[int] = None,
|
||||
|
||||
+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
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"fastapi>=0.104",
|
||||
"uvicorn[standard]>=0.24",
|
||||
"pyserial>=3.5",
|
||||
"sqlalchemy>=2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pyserial
|
||||
+189
-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
|
||||
@@ -1884,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 ")
|
||||
|
||||
@@ -1905,16 +2049,27 @@ class SeismoLab(tk.Tk):
|
||||
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)
|
||||
|
||||
+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
-524
File diff suppressed because it is too large
Load Diff
+525
-145
@@ -59,8 +59,10 @@ except ImportError:
|
||||
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.protocol import ProtocolError
|
||||
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
||||
from minimateplus.models import CallHomeConfig, ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
||||
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
|
||||
from minimateplus.blastware_file import write_blastware_file, blastware_filename
|
||||
from sfm.cache import SFMCache, get_cache
|
||||
from sfm.database import SeismoDb
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -284,11 +286,14 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
|
||||
if cc is None:
|
||||
return None
|
||||
return {
|
||||
"record_time": cc.record_time,
|
||||
"sample_rate": cc.sample_rate,
|
||||
"recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
"sample_rate": cc.sample_rate,
|
||||
"histogram_interval_sec": cc.histogram_interval_sec, # seconds; None if not Histogram mode
|
||||
"record_time": cc.record_time,
|
||||
"trigger_level_geo": cc.trigger_level_geo,
|
||||
"alarm_level_geo": cc.alarm_level_geo,
|
||||
"max_range_geo": cc.max_range_geo,
|
||||
"geo_adc_scale": cc.geo_adc_scale, # hw scale factor (in/s)/V — informational only, do not write
|
||||
"geo_range": cc.geo_range, # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
|
||||
"setup_name": cc.setup_name,
|
||||
"project": cc.project,
|
||||
"client": cc.client,
|
||||
@@ -298,6 +303,27 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
|
||||
}
|
||||
|
||||
|
||||
def _serialise_call_home_config(ch: Optional["CallHomeConfig"]) -> Optional[dict]:
|
||||
if ch is None:
|
||||
return None
|
||||
return {
|
||||
"auto_call_home_enabled": ch.auto_call_home_enabled,
|
||||
"dial_string": ch.dial_string,
|
||||
"after_event_recorded": ch.after_event_recorded,
|
||||
"at_specified_times": ch.at_specified_times,
|
||||
"time1_enabled": ch.time1_enabled,
|
||||
"time1_hour": ch.time1_hour,
|
||||
"time1_min": ch.time1_min,
|
||||
"time2_enabled": ch.time2_enabled,
|
||||
"time2_hour": ch.time2_hour,
|
||||
"time2_min": ch.time2_min,
|
||||
"num_retries": ch.num_retries,
|
||||
"time_between_retries_sec": ch.time_between_retries_sec,
|
||||
"wait_for_connection_sec": ch.wait_for_connection_sec,
|
||||
"warm_up_time_sec": ch.warm_up_time_sec,
|
||||
}
|
||||
|
||||
|
||||
def _serialise_device_info(info: DeviceInfo) -> dict:
|
||||
return {
|
||||
"serial": info.serial,
|
||||
@@ -388,6 +414,33 @@ def _run_with_retry(fn, *, is_tcp: bool):
|
||||
return fn() # let any second failure propagate normally
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _backfill_events(events: list, info: "DeviceInfo") -> None:
|
||||
"""
|
||||
Fill in sample_rate and project_info fields that the per-event waveform
|
||||
record doesn't carry — sourced from the device's compliance config.
|
||||
|
||||
Extracted from device_events() so it can be called from both the full
|
||||
download path and the partial (new-events-only) path.
|
||||
"""
|
||||
if info.compliance_config and info.compliance_config.sample_rate:
|
||||
for ev in events:
|
||||
if ev.sample_rate is None:
|
||||
ev.sample_rate = info.compliance_config.sample_rate
|
||||
|
||||
if info.compliance_config:
|
||||
cc = info.compliance_config
|
||||
for ev in events:
|
||||
if ev.project_info is None:
|
||||
ev.project_info = ProjectInfo()
|
||||
pi = ev.project_info
|
||||
if pi.client is None: pi.client = cc.client
|
||||
if pi.operator is None: pi.operator = cc.operator
|
||||
if pi.sensor_location is None: pi.sensor_location = cc.sensor_location
|
||||
if pi.notes is None: pi.notes = cc.notes
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
@@ -414,7 +467,7 @@ def device_info(
|
||||
baud: int = Query(38400, description="Serial baud rate (default 38400)"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay (e.g. 203.0.113.5)"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
force: bool = Query(False, description="Bypass cache and re-read from device"),
|
||||
force: bool = Query(False, description="Bypass cache and re-read from device"),
|
||||
) -> dict:
|
||||
"""
|
||||
Connect to the device, perform the POLL startup handshake, and return
|
||||
@@ -423,16 +476,21 @@ def device_info(
|
||||
Supply either *port* (serial) or *host* (TCP/modem).
|
||||
Equivalent to POST /device/connect — provided as GET for convenience.
|
||||
|
||||
Response is cached until a POST /device/config write invalidates it.
|
||||
Pass ?force=true to bypass the cache.
|
||||
**Caching**: device identity and compliance config are cached after the first
|
||||
successful read (they rarely change). Pass *force=true* to bypass the cache
|
||||
and re-read directly from the device (e.g. after a config push).
|
||||
The cache is also automatically invalidated after POST /device/config.
|
||||
"""
|
||||
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
|
||||
log.info("GET /device/info port=%s host=%s tcp_port=%d force=%s", port, host, tcp_port, force)
|
||||
|
||||
cache = get_cache()
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
|
||||
if not force:
|
||||
cached = _live_cache.get_device_info(conn_key)
|
||||
cached = cache.get_device_info(conn_key)
|
||||
if cached is not None:
|
||||
log.debug("device_info cache hit for %s", conn_key)
|
||||
log.info("device info cache hit for %s", conn_key)
|
||||
cached["_cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
@@ -454,7 +512,7 @@ def device_info(
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
result = _serialise_device_info(info)
|
||||
_live_cache.set_device_info(conn_key, result)
|
||||
cache.set_device_info(conn_key, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -478,8 +536,8 @@ def device_events(
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
debug: bool = Query(False, description="Include raw record hex for field-layout inspection"),
|
||||
force: bool = Query(False, description="Bypass cache and re-download from device"),
|
||||
debug: bool = Query(False, description="Include raw record hex for field-layout inspection"),
|
||||
force: bool = Query(False, description="Bypass cache and re-download all events from device"),
|
||||
) -> dict:
|
||||
"""
|
||||
Connect to the device, read the event index, and download all stored
|
||||
@@ -497,38 +555,107 @@ def device_events(
|
||||
|
||||
This does NOT download raw ADC waveform samples — those are large and
|
||||
fetched separately via GET /device/event/{idx}/waveform.
|
||||
|
||||
**Caching**: event headers are cached after the first download. On subsequent
|
||||
calls, the device is contacted only to check the event count (fast: ~2s).
|
||||
If the count matches the cache, all events are returned from cache instantly.
|
||||
If new events exist on the device, only the new ones are downloaded and merged.
|
||||
Pass *force=true* to bypass the cache entirely and re-download everything.
|
||||
"""
|
||||
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
|
||||
log.info("GET /device/events port=%s host=%s debug=%s force=%s", port, host, debug, force)
|
||||
|
||||
# ── Cache fast path ───────────────────────────────────────────────────────
|
||||
# Do a quick poll + count_events() probe (~2s over cellular) to check if the
|
||||
# device has any new events. If the count matches the cache, return early.
|
||||
if not force and not debug:
|
||||
try:
|
||||
def _count():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
try:
|
||||
client.poll()
|
||||
except Exception:
|
||||
client.poll()
|
||||
return client.count_events()
|
||||
device_count = _run_with_retry(_count, is_tcp=_is_tcp(host))
|
||||
cached_events = _live_cache.get_events(conn_key, device_count)
|
||||
if cached_events is not None:
|
||||
log.info(" events cache hit (%d events, count=%d)", len(cached_events), device_count)
|
||||
# Also serve cached device info if available
|
||||
cached_info = _live_cache.get_device_info(conn_key)
|
||||
return {
|
||||
"device": cached_info or {},
|
||||
"event_count": len(cached_events),
|
||||
"events": cached_events,
|
||||
"cached": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning(" count probe failed (%s) — falling through to full download", exc)
|
||||
cache = get_cache()
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
|
||||
# ── Full download ─────────────────────────────────────────────────────────
|
||||
# ── Smart cache path (skip when debug=True or force=True) ────────────────
|
||||
# debug mode uses raw_record_hex which isn't stored in the cache, so we
|
||||
# must always go to the device when debug is requested.
|
||||
if not force and not debug:
|
||||
cached_events = cache.get_all_events(conn_key)
|
||||
cached_count = len(cached_events) if cached_events else 0
|
||||
|
||||
if cached_count > 0:
|
||||
# Quick device contact: just count events via the fast 1E/1F chain.
|
||||
# This takes ~2s instead of the full event download (~10-30s).
|
||||
try:
|
||||
def _count():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.connect()
|
||||
return client.count_events()
|
||||
device_count = _run_with_retry(_count, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ProtocolError, OSError, Exception) as exc:
|
||||
# If we can't reach the device at all, serve stale cache rather
|
||||
# than returning an error — field units go offline regularly.
|
||||
log.warning("count_events failed (%s) — serving stale cache for %s", exc, conn_key)
|
||||
cached_info = cache.get_device_info(conn_key) or {}
|
||||
return {
|
||||
"device": cached_info,
|
||||
"event_count": cached_count,
|
||||
"events": cached_events,
|
||||
"_cached": True,
|
||||
"_stale": True,
|
||||
}
|
||||
|
||||
if device_count == cached_count:
|
||||
# Nothing new — return cache immediately, no event download needed.
|
||||
log.info(
|
||||
"event cache hit for %s: %d events, device count matches",
|
||||
conn_key, cached_count,
|
||||
)
|
||||
cached_info = cache.get_device_info(conn_key) or {}
|
||||
return {
|
||||
"device": cached_info,
|
||||
"event_count": cached_count,
|
||||
"events": cached_events,
|
||||
"_cached": True,
|
||||
}
|
||||
|
||||
if device_count > cached_count:
|
||||
# New events on the device — download all events but only store/return
|
||||
# the new ones. Events are append-only; indices 0..(cached_count-1)
|
||||
# are already in the cache and don't need to be re-downloaded logically,
|
||||
# but the protocol requires iterating from event 0 to reach later ones.
|
||||
# The device download time is dominated by the number of events requested,
|
||||
# so we stop at the last known event index to avoid re-downloading everything.
|
||||
log.info(
|
||||
"new events on device %s: have %d, device has %d — fetching all up to %d",
|
||||
conn_key, cached_count, device_count, device_count - 1,
|
||||
)
|
||||
try:
|
||||
def _fetch_new():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
info = client.connect()
|
||||
all_evs = client.get_events(stop_after_index=device_count - 1)
|
||||
return info, all_evs
|
||||
info, all_events = _run_with_retry(_fetch_new, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
_backfill_events(all_events, info)
|
||||
# Only the new events (indices >= cached_count) are truly new.
|
||||
new_events = [ev for ev in all_events if ev.index >= cached_count]
|
||||
new_serialised = [_serialise_event(ev) for ev in new_events]
|
||||
cache.set_events(conn_key, new_serialised)
|
||||
cache.set_device_info(conn_key, _serialise_device_info(info))
|
||||
|
||||
merged_events = cache.get_all_events(conn_key)
|
||||
return {
|
||||
"device": _serialise_device_info(info),
|
||||
"event_count": len(merged_events),
|
||||
"events": merged_events,
|
||||
"_cached": True,
|
||||
"_new_events": len(new_events),
|
||||
}
|
||||
|
||||
# ── Full download path (first call, force=True, or debug=True) ───────────
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
@@ -543,23 +670,14 @@ def device_events(
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
# Fill sample_rate from compliance config where the event record doesn't supply it.
|
||||
if info.compliance_config and info.compliance_config.sample_rate:
|
||||
for ev in events:
|
||||
if ev.sample_rate is None:
|
||||
ev.sample_rate = info.compliance_config.sample_rate
|
||||
_backfill_events(events, info)
|
||||
serialised = [_serialise_event(ev, debug=debug) for ev in events]
|
||||
|
||||
# Backfill event.project_info fields that the 210-byte waveform record doesn't carry.
|
||||
if info.compliance_config:
|
||||
cc = info.compliance_config
|
||||
for ev in events:
|
||||
if ev.project_info is None:
|
||||
ev.project_info = ProjectInfo()
|
||||
pi = ev.project_info
|
||||
if pi.client is None: pi.client = cc.client
|
||||
if pi.operator is None: pi.operator = cc.operator
|
||||
if pi.sensor_location is None: pi.sensor_location = cc.sensor_location
|
||||
if pi.notes is None: pi.notes = cc.notes
|
||||
if not debug:
|
||||
# Only cache when not in debug mode (debug adds raw_record_hex which
|
||||
# we don't want polluting the normal cache entries).
|
||||
cache.set_events(conn_key, serialised)
|
||||
cache.set_device_info(conn_key, _serialise_device_info(info))
|
||||
|
||||
serialised_info = _serialise_device_info(info)
|
||||
serialised_events = [_serialise_event(ev, debug=debug) for ev in events]
|
||||
@@ -572,8 +690,7 @@ def device_events(
|
||||
return {
|
||||
"device": serialised_info,
|
||||
"event_count": len(events),
|
||||
"events": serialised_events,
|
||||
"cached": False,
|
||||
"events": serialised,
|
||||
}
|
||||
|
||||
|
||||
@@ -584,21 +701,36 @@ def device_event(
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
force: bool = Query(False, description="Bypass cache and re-download from device"),
|
||||
) -> dict:
|
||||
"""
|
||||
Download a single event by index (0-based).
|
||||
|
||||
Supply either *port* (serial) or *host* (TCP/modem).
|
||||
Performs: POLL startup → event index → event header → waveform record.
|
||||
|
||||
**Caching**: if this event was already downloaded (e.g. via GET /device/events),
|
||||
it is returned instantly from cache with no device contact.
|
||||
"""
|
||||
log.info("GET /device/event/%d port=%s host=%s", index, port, host)
|
||||
log.info("GET /device/event/%d port=%s host=%s force=%s", index, port, host, force)
|
||||
|
||||
cache = get_cache()
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
|
||||
if not force:
|
||||
cached = cache.get_event(conn_key, index)
|
||||
if cached is not None:
|
||||
log.info("event cache hit for %s index %d", conn_key, index)
|
||||
cached["_cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.connect()
|
||||
return client.get_events(stop_after_index=index)
|
||||
events = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
info = client.connect()
|
||||
events = client.get_events(stop_after_index=index)
|
||||
return info, events
|
||||
info, events = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
@@ -615,7 +747,14 @@ def device_event(
|
||||
detail=f"Event index {index} not found on device",
|
||||
)
|
||||
|
||||
return _serialise_event(matching[0])
|
||||
_backfill_events(matching, info)
|
||||
result = _serialise_event(matching[0])
|
||||
|
||||
# Store all downloaded events (we paid for them anyway — indices 0..index)
|
||||
all_serialised = [_serialise_event(ev) for ev in events]
|
||||
cache.set_events(conn_key, all_serialised)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/device/event/{index}/waveform")
|
||||
@@ -625,7 +764,7 @@ def device_event_waveform(
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
force: bool = Query(False, description="Bypass cache and re-download waveform"),
|
||||
force: bool = Query(False, description="Bypass cache and re-download from device"),
|
||||
) -> dict:
|
||||
"""
|
||||
Download the full raw ADC waveform for a single event (0-based index).
|
||||
@@ -645,28 +784,29 @@ def device_event_waveform(
|
||||
- **channels**: dict of channel name → list of signed int16 ADC counts
|
||||
(keys: "Tran", "Vert", "Long", "Mic")
|
||||
|
||||
Waveforms are immutable once recorded and are cached permanently per
|
||||
(connection, event index). Pass ?force=true to re-download.
|
||||
**Caching**: full waveforms are cached permanently after the first download —
|
||||
they are immutable once recorded on the device. Subsequent requests for the
|
||||
same event return instantly from cache without any device contact.
|
||||
Pass *force=true* to force a fresh download (rarely needed).
|
||||
"""
|
||||
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
|
||||
log.info("GET /device/event/%d/waveform port=%s host=%s force=%s", index, port, host, force)
|
||||
|
||||
cache = get_cache()
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
|
||||
if not force:
|
||||
cached_waveform = _live_cache.get_waveform(conn_key, index)
|
||||
if cached_waveform is not None:
|
||||
log.debug("waveform cache hit: %s event %d", conn_key, index)
|
||||
return cached_waveform
|
||||
cached = cache.get_waveform(conn_key, index)
|
||||
if cached is not None:
|
||||
log.info("waveform cache hit for %s event %d", conn_key, index)
|
||||
cached["_cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
|
||||
info = client.connect()
|
||||
# stop_after_index avoids downloading events beyond the one requested.
|
||||
events = client.get_events(
|
||||
full_waveform=True,
|
||||
stop_after_index=index,
|
||||
compliance_config=info.compliance_config if info else None,
|
||||
)
|
||||
events = client.get_events(full_waveform=True, stop_after_index=index)
|
||||
matching = [ev for ev in events if ev.index == index]
|
||||
return matching[0] if matching else None, info
|
||||
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
@@ -693,31 +833,125 @@ def device_event_waveform(
|
||||
if sample_rate is None and info.compliance_config:
|
||||
sample_rate = info.compliance_config.sample_rate
|
||||
|
||||
# Recompute rectime_seconds using the actual sample rate now that we have it.
|
||||
# _decode_a5_waveform used 1024 sps as default; override if device says otherwise.
|
||||
# strt[18] is a record-mode byte (0x46 / 0x0E), NOT rectime in seconds.
|
||||
rectime_seconds = ev.rectime_seconds
|
||||
if (ev.total_samples is not None and ev.pretrig_samples is not None
|
||||
and sample_rate and sample_rate > 0):
|
||||
post_trig = max(0, ev.total_samples - ev.pretrig_samples)
|
||||
rectime_seconds = round(post_trig / sample_rate, 2)
|
||||
|
||||
result = {
|
||||
"index": ev.index,
|
||||
"record_type": ev.record_type,
|
||||
"timestamp": _serialise_timestamp(ev.timestamp),
|
||||
"total_samples": ev.total_samples,
|
||||
"pretrig_samples": ev.pretrig_samples,
|
||||
"rectime_seconds": rectime_seconds,
|
||||
"rectime_seconds": ev.rectime_seconds,
|
||||
"samples_decoded": samples_decoded,
|
||||
"sample_rate": sample_rate,
|
||||
"peak_values": _serialise_peak_values(ev.peak_values),
|
||||
"channels": raw,
|
||||
}
|
||||
_live_cache.set_waveform(conn_key, index, result)
|
||||
cache.set_waveform(conn_key, index, result)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/device/event/{index}/blastware_file")
|
||||
def device_event_blastware_file(
|
||||
index: int,
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> FileResponse:
|
||||
"""
|
||||
Download the waveform for a single event (0-based index) and return it
|
||||
as a Blastware-compatible binary file with a correct Blastware filename.
|
||||
|
||||
Supply either *port* (serial) or *host* (TCP/modem).
|
||||
|
||||
The file is written to /tmp and streamed back as a binary download.
|
||||
Blastware can open it directly — filename encodes serial + timestamp.
|
||||
|
||||
Filename format: <prefix><serial3><stem><AB>0<W|H>
|
||||
- prefix letter = chr(ord('B') + floor(serial_numeric / 1000))
|
||||
- stem + AB = second-resolution timestamp since 1985-01-01 local
|
||||
- W / H = Full Waveform / Full Histogram (defaults to W for
|
||||
triggered events; histogram requires recording_mode
|
||||
to be populated from compliance config)
|
||||
|
||||
Performs: POLL startup → get_events(full_waveform=False, extra_chunks=1,
|
||||
stop_after_index=index) → write_blastware_file() → FileResponse.
|
||||
"""
|
||||
log.info(
|
||||
"GET /device/event/%d/blastware_file port=%s host=%s",
|
||||
index, port, host,
|
||||
)
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
|
||||
info = client.connect()
|
||||
# Use stop_after_metadata=True (full_waveform=False) with 1 extra
|
||||
# chunk after "Project:". The extra chunk primes the device so that
|
||||
# the termination response carries the full waveform footer bytes.
|
||||
# Without it the terminator returns only ~90 bytes (no useful footer).
|
||||
#
|
||||
# The extra chunk's ADC data IS part of the Blastware file body —
|
||||
# confirmed from 4-27-26 TCP capture: all 14 A5 frames (including the
|
||||
# extra chunk's 2 TCP sub-frames) contribute to the correct 6864-byte
|
||||
# output. write_blastware_file() includes all frames unconditionally.
|
||||
#
|
||||
# full_waveform=True (natural end-of-stream) downloads ALL chunks
|
||||
# including post-event silence (35+ chunks for a 9-sec event at
|
||||
# 1024 sps) — this produces 24KB+ files that Blastware rejects.
|
||||
events = client.get_events(
|
||||
full_waveform=False,
|
||||
stop_after_index=index,
|
||||
extra_chunks_after_metadata=1,
|
||||
)
|
||||
matching = [ev for ev in events if ev.index == index]
|
||||
return matching[0] if matching else None, info
|
||||
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
log.error("blastware_file: protocol error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
log.error("blastware_file: connection error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
log.error("blastware_file: unexpected error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
if ev is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Event index {index} not found on device",
|
||||
)
|
||||
|
||||
a5_frames = getattr(ev, "_a5_frames", None)
|
||||
if not a5_frames:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"No waveform data received for event index {index} — 5A download failed",
|
||||
)
|
||||
|
||||
# Determine serial number from device info
|
||||
serial = getattr(info, "serial", None) or "UNKNOWN"
|
||||
|
||||
# Build filename using the same algorithm Blastware uses
|
||||
filename = blastware_filename(ev, serial)
|
||||
|
||||
# Write to /tmp so FastAPI can stream it back
|
||||
out_path = Path("/tmp") / filename
|
||||
write_blastware_file(ev, a5_frames, out_path)
|
||||
log.info(
|
||||
"blastware_file: wrote %s (%d A5 frames, serial=%s)",
|
||||
out_path, len(a5_frames), serial,
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(out_path),
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
# ── Write endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
class DeviceConfigBody(BaseModel):
|
||||
@@ -729,16 +963,15 @@ class DeviceConfigBody(BaseModel):
|
||||
|
||||
Recording parameters
|
||||
--------------------
|
||||
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
|
||||
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
|
||||
recording_mode : Recording mode enum. Values: 0=Single Shot, 1=Continuous, 3=Histogram, 4=Histogram+Continuous.
|
||||
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
|
||||
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
|
||||
|
||||
Trigger / alarm thresholds (geo channels, in/s)
|
||||
------------------------------------------------
|
||||
Trigger / alarm thresholds and range (geo channels)
|
||||
----------------------------------------------------
|
||||
trigger_level_geo : Trigger threshold in in/s (e.g. 0.5).
|
||||
alarm_level_geo : Alarm threshold in in/s (e.g. 1.0).
|
||||
max_range_geo : Full-scale calibration constant (e.g. 6.206).
|
||||
Rarely changed — only set if you know what you're doing.
|
||||
|
||||
geo_range : Geophone range/sensitivity. 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s.
|
||||
Project / operator strings (max 41 ASCII characters each)
|
||||
----------------------------
|
||||
project : Project description.
|
||||
@@ -748,12 +981,14 @@ class DeviceConfigBody(BaseModel):
|
||||
notes : Extended notes.
|
||||
"""
|
||||
# Recording parameters
|
||||
sample_rate: Optional[int] = None
|
||||
record_time: Optional[float] = None
|
||||
# Threshold parameters
|
||||
recording_mode: Optional[int] = None
|
||||
sample_rate: Optional[int] = None
|
||||
record_time: Optional[float] = None
|
||||
histogram_interval_sec: Optional[int] = None # seconds: 2, 5, 15, 60, 300, 900 (mode-gated)
|
||||
# Threshold parameters / geo range
|
||||
trigger_level_geo: Optional[float] = None
|
||||
alarm_level_geo: Optional[float] = None
|
||||
max_range_geo: Optional[float] = None
|
||||
geo_range: Optional[int] = None # 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s
|
||||
# Project / operator strings
|
||||
project: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
@@ -781,6 +1016,7 @@ def device_config(
|
||||
|
||||
Example body (all fields optional — include only what you want to change):
|
||||
{
|
||||
"recording_mode": 1,
|
||||
"sample_rate": 1024,
|
||||
"record_time": 3.0,
|
||||
"trigger_level_geo": 0.5,
|
||||
@@ -808,11 +1044,13 @@ def device_config(
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.connect()
|
||||
client.apply_config(
|
||||
recording_mode=body.recording_mode,
|
||||
sample_rate=body.sample_rate,
|
||||
record_time=body.record_time,
|
||||
histogram_interval_sec=body.histogram_interval_sec,
|
||||
trigger_level_geo=body.trigger_level_geo,
|
||||
alarm_level_geo=body.alarm_level_geo,
|
||||
max_range_geo=body.max_range_geo,
|
||||
geo_range=body.geo_range,
|
||||
project=body.project,
|
||||
client_name=body.client_name,
|
||||
operator=body.operator,
|
||||
@@ -831,9 +1069,9 @@ def device_config(
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
# Config was written — invalidate cached device info and events so the next
|
||||
# /device/info or /device/events call re-reads fresh data from the device.
|
||||
_live_cache.mark_config_dirty(conn_key)
|
||||
# Config was written to the device — the cached compliance config is now stale.
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
get_cache().mark_config_dirty(conn_key)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -875,14 +1113,17 @@ def device_monitor_status(
|
||||
Returns is_monitoring bool, battery voltage, and memory usage (total + free
|
||||
bytes). Battery and memory are only present when the unit is idle.
|
||||
|
||||
**Caching:** response is cached for 30 seconds. Pass ?force=true to bypass.
|
||||
**Caching**: status is cached for 30 seconds to reduce cellular polling overhead.
|
||||
Pass *force=true* to bypass the cache for an immediate fresh read.
|
||||
"""
|
||||
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
|
||||
cache = get_cache()
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
|
||||
if not force:
|
||||
cached = _live_cache.get_monitor_status(conn_key)
|
||||
cached = cache.get_monitor_status(conn_key)
|
||||
if cached is not None:
|
||||
log.debug("monitor_status cache hit for %s", conn_key)
|
||||
log.debug("monitor status cache hit for %s", conn_key)
|
||||
cached["_cached"] = True
|
||||
return cached
|
||||
|
||||
with _build_client(port=port, baud=baud, host=host, tcp_port=tcp_port) as client:
|
||||
@@ -903,7 +1144,7 @@ def device_monitor_status(
|
||||
result["memory_free_bytes"] = status.memory_free
|
||||
result["memory_free_kb"] = round(status.memory_free / 1024, 1)
|
||||
|
||||
_live_cache.set_monitor_status(conn_key, result)
|
||||
cache.set_monitor_status(conn_key, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -927,7 +1168,9 @@ def device_monitor_start(
|
||||
log.warning("start monitoring poll retry: %s", exc)
|
||||
client.poll()
|
||||
client.start_monitoring()
|
||||
_live_cache.invalidate_monitor_status(conn_key)
|
||||
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
get_cache().invalidate_monitor_status(conn_key)
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@@ -951,10 +1194,182 @@ def device_monitor_stop(
|
||||
log.warning("stop monitoring poll retry: %s", exc)
|
||||
client.poll()
|
||||
client.stop_monitoring()
|
||||
_live_cache.invalidate_monitor_status(conn_key)
|
||||
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
get_cache().invalidate_monitor_status(conn_key)
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
# ── Call home config endpoints ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/device/call_home")
|
||||
def device_call_home_get(
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> dict:
|
||||
"""
|
||||
Read the Auto Call Home (ACH) configuration from the device.
|
||||
|
||||
Sends SUB 0x2C (two-step read) and returns the decoded call home config.
|
||||
|
||||
Confirmed from 4-20-26 call home settings captures (BE11529).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"auto_call_home_enabled": true/false,
|
||||
"dial_string": "RADIO RING",
|
||||
"after_event_recorded": true/false,
|
||||
"at_specified_times": true/false,
|
||||
"time1_enabled": true/false, "time1_hour": 19, "time1_min": 55,
|
||||
"time2_enabled": false, "time2_hour": 0, "time2_min": 0,
|
||||
"num_retries": 3,
|
||||
"time_between_retries_sec": 15,
|
||||
"wait_for_connection_sec": 60,
|
||||
"warm_up_time_sec": 60
|
||||
}
|
||||
"""
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.poll()
|
||||
return client.get_call_home_config()
|
||||
ch_config = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
return _serialise_call_home_config(ch_config) or {}
|
||||
|
||||
|
||||
class CallHomeConfigBody(BaseModel):
|
||||
"""
|
||||
Request body for POST /device/call_home.
|
||||
|
||||
All fields are optional — only supplied (non-null) fields are modified.
|
||||
All other call home config bytes are round-tripped verbatim from the device.
|
||||
|
||||
Confirmed writable fields (4-20-26 captures):
|
||||
auto_call_home_enabled : bool — master enable for auto call home
|
||||
after_event_recorded : bool — call home after each triggered event
|
||||
at_specified_times : bool — enable time-based scheduled calls
|
||||
time1_enabled : bool — enable time slot 1
|
||||
time1_hour : int — hour for slot 1 (0-23; avoid 3 — DLE escape limitation)
|
||||
time1_min : int — minute for slot 1 (0-59; avoid 3)
|
||||
time2_enabled : bool — enable time slot 2
|
||||
time2_hour : int — hour for slot 2 (0-23; avoid 3)
|
||||
time2_min : int — minute for slot 2 (0-59; avoid 3)
|
||||
|
||||
Read-only fields (not writable via this endpoint):
|
||||
dial_string, num_retries, time_between_retries_sec,
|
||||
wait_for_connection_sec, warm_up_time_sec
|
||||
"""
|
||||
auto_call_home_enabled: Optional[bool] = None
|
||||
after_event_recorded: Optional[bool] = None
|
||||
at_specified_times: Optional[bool] = None
|
||||
time1_enabled: Optional[bool] = None
|
||||
time1_hour: Optional[int] = None
|
||||
time1_min: Optional[int] = None
|
||||
time2_enabled: Optional[bool] = None
|
||||
time2_hour: Optional[int] = None
|
||||
time2_min: Optional[int] = None
|
||||
|
||||
|
||||
@app.post("/device/call_home")
|
||||
def device_call_home_set(
|
||||
body: CallHomeConfigBody,
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> dict:
|
||||
"""
|
||||
Read the current call home config, apply supplied changes, and write back.
|
||||
|
||||
Only non-null fields are modified. All other bytes round-trip verbatim.
|
||||
|
||||
Write sequence (confirmed from 4-20-26 call home settings captures):
|
||||
SUB 0x2C (read 2-step) → 125-byte raw payload
|
||||
patch fields
|
||||
SUB 0x7E (write 127-byte payload) → ack 0x81
|
||||
SUB 0x7F (confirm) → ack 0x80
|
||||
|
||||
Example body:
|
||||
{ "auto_call_home_enabled": true, "after_event_recorded": true,
|
||||
"time1_enabled": true, "time1_hour": 20, "time1_min": 0 }
|
||||
"""
|
||||
changed = body.model_dump(exclude_none=True)
|
||||
log.info("POST /device/call_home port=%s host=%s fields=%s", port, host, list(changed.keys()))
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.poll()
|
||||
client.set_call_home_config(
|
||||
auto_call_home_enabled=body.auto_call_home_enabled,
|
||||
after_event_recorded=body.after_event_recorded,
|
||||
at_specified_times=body.at_specified_times,
|
||||
time1_enabled=body.time1_enabled,
|
||||
time1_hour=body.time1_hour,
|
||||
time1_min=body.time1_min,
|
||||
time2_enabled=body.time2_enabled,
|
||||
time2_hour=body.time2_hour,
|
||||
time2_min=body.time2_min,
|
||||
)
|
||||
_run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
return {"status": "ok", "updated_fields": changed}
|
||||
|
||||
|
||||
# ── Cache management endpoints ────────────────────────────────────────────────
|
||||
|
||||
@app.get("/cache/stats")
|
||||
def cache_stats() -> dict:
|
||||
"""
|
||||
Return row counts for all cache tables.
|
||||
|
||||
Useful for debugging and verifying that caching is working as expected.
|
||||
"""
|
||||
return get_cache().stats()
|
||||
|
||||
|
||||
@app.delete("/cache/device")
|
||||
def cache_clear_device(
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> dict:
|
||||
"""
|
||||
Clear all cached data for a specific device (identified by its connection address).
|
||||
|
||||
Clears: device info, all event headers, all waveforms, monitor status.
|
||||
The next request to any endpoint for this device will re-fetch from the device.
|
||||
|
||||
Supply either *port* (serial) or *host* (TCP/modem) to identify the device.
|
||||
"""
|
||||
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
|
||||
counts = get_cache().clear_device(conn_key)
|
||||
return {"status": "cleared", "conn_key": conn_key, "deleted": counts}
|
||||
|
||||
|
||||
# ── DB read endpoints ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# These endpoints expose the seismo-relay SQLite DB written by ach_server.py.
|
||||
@@ -1019,41 +1434,6 @@ def db_set_false_trigger(
|
||||
return {"status": "ok", "event_id": event_id, "false_trigger": value}
|
||||
|
||||
|
||||
@app.get("/db/events/{event_id}/waveform")
|
||||
def db_event_waveform(event_id: str) -> dict:
|
||||
"""
|
||||
Return the stored waveform blob for a DB event.
|
||||
|
||||
The response shape is identical to GET /device/event/{index}/waveform so the
|
||||
waveform viewer can consume either source without modification:
|
||||
- total_samples, pretrig_samples, rectime_seconds, samples_decoded
|
||||
- sample_rate
|
||||
- peak_values (tran_in_s, vert_in_s, long_in_s, micl_psi, peak_vector_sum)
|
||||
- channels ({"Tran": [...], "Vert": [...], "Long": [...], "Mic": [...]})
|
||||
|
||||
Returns 404 if the event doesn't exist, 422 if the event exists but has no
|
||||
stored waveform (downloaded before waveform storage was implemented).
|
||||
"""
|
||||
import json as _json
|
||||
db = _get_db()
|
||||
found, blob_str = db.get_event_waveform(event_id)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||
if blob_str is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Event {event_id} has no stored waveform. "
|
||||
"Waveform storage requires ACH server v0.11+. "
|
||||
"Re-download the event from the device to backfill."
|
||||
),
|
||||
)
|
||||
try:
|
||||
return _json.loads(blob_str)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Waveform blob corrupt: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/db/monitor_log")
|
||||
def db_monitor_log(
|
||||
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
||||
|
||||
+340
-241
@@ -548,18 +548,6 @@
|
||||
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
|
||||
.ft-toggle-btn.flagged { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.1); }
|
||||
|
||||
.wf-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 1px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wf-btn:hover { background: rgba(56,139,253,0.15); border-color: var(--accent); }
|
||||
|
||||
.db-empty {
|
||||
color: var(--text-mute);
|
||||
font-size: 13px;
|
||||
@@ -748,9 +736,10 @@
|
||||
|
||||
<!-- ── Live tab bar ───────────────────────────────────────────────── -->
|
||||
<div class="tab-bar" id="live-tab-bar">
|
||||
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
|
||||
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
|
||||
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
|
||||
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
|
||||
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
|
||||
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
|
||||
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
@@ -782,12 +771,6 @@
|
||||
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
||||
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled>◀</button>
|
||||
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled>▶</button>
|
||||
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
|
||||
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
|
||||
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
|
||||
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
|
||||
Force reload
|
||||
</label>
|
||||
<div class="event-chips" id="event-chips"></div>
|
||||
</div>
|
||||
|
||||
@@ -799,14 +782,6 @@
|
||||
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
|
||||
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
|
||||
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
|
||||
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
|
||||
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
|
||||
<div id="debug-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="waveform-area" style="flex:1; overflow-y:auto;">
|
||||
<div id="empty-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -829,6 +804,17 @@
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Recording</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Recording Mode</label>
|
||||
<select id="cfg-recording-mode">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="0">Single Shot</option>
|
||||
<option value="1">Continuous</option>
|
||||
<option value="3">Histogram</option>
|
||||
<option value="4">Histogram + Continuous</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Sample Rate</label>
|
||||
<select id="cfg-sample-rate">
|
||||
@@ -839,6 +825,20 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Histogram Interval</label>
|
||||
<select id="cfg-histogram-interval">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="2">2 seconds</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
<option value="300">5 minutes</option>
|
||||
<option value="900">15 minutes</option>
|
||||
</select>
|
||||
<div class="hint">Only active in Histogram / Histogram + Continuous mode</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Record Time (seconds)</label>
|
||||
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
|
||||
@@ -858,10 +858,15 @@
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Max Range — Geo (in/s)</label>
|
||||
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" />
|
||||
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</div>
|
||||
<label>Maximum Range — Geo</label>
|
||||
<select id="cfg-geo-range">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="0">Normal — 10.000 in/s</option>
|
||||
<option value="1">Sensitive — 1.250 in/s</option>
|
||||
</select>
|
||||
<div class="hint">Geophone sensitivity (applies to Tran / Vert / Long channels)</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Project / operator strings -->
|
||||
@@ -905,6 +910,123 @@
|
||||
|
||||
</div><!-- end #tab-config -->
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
TAB: Call Home
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<div id="tab-call-home" class="tab-pane">
|
||||
|
||||
<div class="cfg-grid">
|
||||
|
||||
<!-- Enable / dial -->
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Auto Call Home</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Enable Auto Call Home</label>
|
||||
<select id="ch-enabled">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Dial String</label>
|
||||
<input type="text" id="ch-dial-string" disabled placeholder="Read-only (e.g. RADIO RING)" />
|
||||
<div class="hint">Read from device — not writable via this interface</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-section-title" style="margin-top:16px">When to Call</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>After Event Recorded</label>
|
||||
<select id="ch-after-event">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>At Specified Times</label>
|
||||
<select id="ch-at-times">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Scheduled call times -->
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Scheduled Call Times</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Slot 1</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="ch-t1-enabled" style="width:120px">
|
||||
<option value="">— enable —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<input type="number" id="ch-t1-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
|
||||
<span>:</span>
|
||||
<input type="number" id="ch-t1-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
|
||||
</div>
|
||||
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Slot 2</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="ch-t2-enabled" style="width:120px">
|
||||
<option value="">— enable —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<input type="number" id="ch-t2-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
|
||||
<span>:</span>
|
||||
<input type="number" id="ch-t2-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
|
||||
</div>
|
||||
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-section-title" style="margin-top:16px">Retry Settings (read-only)</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Number of Retries</label>
|
||||
<input type="text" id="ch-num-retries" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Between Retries (s)</label>
|
||||
<input type="text" id="ch-retry-gap" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Wait for Connection (s)</label>
|
||||
<input type="text" id="ch-wait-conn" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Warm-up Time (s)</label>
|
||||
<input type="text" id="ch-warmup" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="cfg-actions">
|
||||
<button class="btn btn-ghost" id="ch-read-btn" onclick="readCallHome()" disabled>Read from Device</button>
|
||||
<button class="btn btn-success" id="ch-write-btn" onclick="writeCallHome()" disabled>Write to Device</button>
|
||||
<button class="btn btn-ghost" onclick="clearCallHomeForm()">Clear Form</button>
|
||||
<span id="ch-status"></span>
|
||||
</div>
|
||||
|
||||
</div><!-- end #tab-call-home -->
|
||||
|
||||
</div><!-- end #section-live -->
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
@@ -947,7 +1069,6 @@
|
||||
<table class="db-table" id="hist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Timestamp</th>
|
||||
<th>Serial</th>
|
||||
<th>Tran (in/s)</th>
|
||||
@@ -1064,8 +1185,7 @@ let unitInfo = null;
|
||||
let eventList = [];
|
||||
let currentEvent = 0;
|
||||
let charts = {};
|
||||
let geoRange = 6.206;
|
||||
let lastWaveformData = null; // last successfully rendered waveform payload
|
||||
let geoAdcScale = 6.206;
|
||||
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
|
||||
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
|
||||
|
||||
@@ -1190,6 +1310,8 @@ async function connectUnit() {
|
||||
document.getElementById('next-btn').disabled = eventList.length <= 1;
|
||||
document.getElementById('cfg-read-btn').disabled = false;
|
||||
document.getElementById('cfg-write-btn').disabled = false;
|
||||
document.getElementById('ch-read-btn').disabled = false;
|
||||
document.getElementById('ch-write-btn').disabled = false;
|
||||
|
||||
btn.disabled = false; btn.textContent = 'Reconnect';
|
||||
|
||||
@@ -1217,7 +1339,7 @@ function populateDeviceBar() {
|
||||
qs('di-project').textContent = cc.project || '—';
|
||||
qs('di-client').textContent = cc.client || '—';
|
||||
qs('di-operator').textContent = cc.operator || '—';
|
||||
geoRange = cc.max_range_geo ?? 6.206;
|
||||
geoAdcScale = cc.geo_adc_scale ?? 6.206;
|
||||
}
|
||||
|
||||
// ── Monitoring ─────────────────────────────────────────────────────────────────
|
||||
@@ -1354,12 +1476,16 @@ function populateDeviceTab() {
|
||||
|
||||
// Compliance table
|
||||
const cc = unitInfo.compliance_config || {};
|
||||
const RECORDING_MODE_LABELS = {0: 'Single Shot', 1: 'Continuous', 3: 'Histogram', 4: 'Histogram + Continuous'};
|
||||
const complianceRows = [
|
||||
['Recording Mode', cc.recording_mode != null ? (RECORDING_MODE_LABELS[cc.recording_mode] || `0x${cc.recording_mode.toString(16).padStart(2,'0')}`) : '—'],
|
||||
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
|
||||
['Histogram Interval', cc.histogram_interval_sec != null ? (() => { const s = cc.histogram_interval_sec; return s < 60 ? `${s}s` : `${s/60}m`; })() : '—'],
|
||||
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
|
||||
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
|
||||
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
|
||||
['Max Range (geo)', cc.max_range_geo != null ? `${cc.max_range_geo.toFixed(4)} in/s` : '—'],
|
||||
['Max Range (geo)', cc.geo_range != null ? (cc.geo_range === 0 ? 'Normal — 10.000 in/s' : cc.geo_range === 1 ? 'Sensitive — 1.250 in/s' : `0x${cc.geo_range.toString(16).padStart(2,'0')}`) : '—'],
|
||||
['ADC Scale Factor (geo)', cc.geo_adc_scale != null ? `${cc.geo_adc_scale.toFixed(4)} in/s` : '—'],
|
||||
['Setup Name', cc.setup_name || '—'],
|
||||
];
|
||||
renderTable('compliance-table', complianceRows);
|
||||
@@ -1390,11 +1516,13 @@ function renderTable(id, rows) {
|
||||
function populateConfigFromDeviceInfo() {
|
||||
if (!unitInfo) return;
|
||||
const cc = unitInfo.compliance_config || {};
|
||||
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
|
||||
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
|
||||
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
|
||||
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
|
||||
if (cc.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4));
|
||||
if (cc.recording_mode != null) qs('cfg-recording-mode', String(cc.recording_mode));
|
||||
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
|
||||
if (cc.histogram_interval_sec != null) qs('cfg-histogram-interval', String(cc.histogram_interval_sec));
|
||||
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
|
||||
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
|
||||
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
|
||||
if (cc.geo_range != null) qs('cfg-geo-range', String(cc.geo_range));
|
||||
if (cc.project) qs('cfg-project', cc.project);
|
||||
if (cc.client) qs('cfg-client', cc.client);
|
||||
if (cc.operator) qs('cfg-operator', cc.operator);
|
||||
@@ -1403,8 +1531,9 @@ function populateConfigFromDeviceInfo() {
|
||||
}
|
||||
|
||||
function clearConfigForm() {
|
||||
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm','cfg-max-range',
|
||||
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes']
|
||||
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm',
|
||||
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes',
|
||||
'cfg-recording-mode','cfg-histogram-interval','cfg-geo-range']
|
||||
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
|
||||
setCfgStatus('');
|
||||
}
|
||||
@@ -1433,16 +1562,20 @@ async function writeConfig() {
|
||||
|
||||
// Build body — only include fields that have values
|
||||
const body = {};
|
||||
const rm = qs('cfg-recording-mode').value;
|
||||
if (rm !== '') body.recording_mode = parseInt(rm, 10);
|
||||
const sr = qs('cfg-sample-rate').value;
|
||||
if (sr) body.sample_rate = parseInt(sr, 10);
|
||||
const hi = qs('cfg-histogram-interval').value;
|
||||
if (hi !== '') body.histogram_interval_sec = parseInt(hi, 10);
|
||||
const rt = qs('cfg-record-time').value;
|
||||
if (rt) body.record_time = parseFloat(rt);
|
||||
const trig = qs('cfg-trigger').value;
|
||||
if (trig) body.trigger_level_geo = parseFloat(trig);
|
||||
const alarm = qs('cfg-alarm').value;
|
||||
if (alarm) body.alarm_level_geo = parseFloat(alarm);
|
||||
const mr = qs('cfg-max-range').value;
|
||||
if (mr) body.max_range_geo = parseFloat(mr);
|
||||
const gr = qs('cfg-geo-range').value;
|
||||
if (gr !== '') body.geo_range = parseInt(gr, 10);
|
||||
const proj = qs('cfg-project').value.trim();
|
||||
if (proj) body.project = proj;
|
||||
const cli = qs('cfg-client').value.trim();
|
||||
@@ -1481,6 +1614,134 @@ async function writeConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Call Home form ─────────────────────────────────────────────────────────────
|
||||
function setChStatus(msg, type) {
|
||||
const el = document.getElementById('ch-status');
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'ok' ? '#4caf50' : type === 'error' ? '#f44336' : '#aaa';
|
||||
}
|
||||
|
||||
function populateCallHomeForm(ch) {
|
||||
if (!ch) return;
|
||||
const qs2 = id => document.getElementById(id);
|
||||
|
||||
// Read-only display fields
|
||||
if (ch.dial_string != null) qs2('ch-dial-string').value = ch.dial_string || '';
|
||||
if (ch.num_retries != null) qs2('ch-num-retries').value = ch.num_retries;
|
||||
if (ch.time_between_retries_sec != null) qs2('ch-retry-gap').value = ch.time_between_retries_sec;
|
||||
if (ch.wait_for_connection_sec != null) qs2('ch-wait-conn').value = ch.wait_for_connection_sec;
|
||||
if (ch.warm_up_time_sec != null) qs2('ch-warmup').value = ch.warm_up_time_sec;
|
||||
|
||||
// Editable select/input fields (use "" for "unchanged" state when value is null)
|
||||
function setBool(id, val) {
|
||||
if (val != null) document.getElementById(id).value = val ? 'true' : 'false';
|
||||
}
|
||||
setBool('ch-enabled', ch.auto_call_home_enabled);
|
||||
setBool('ch-after-event', ch.after_event_recorded);
|
||||
setBool('ch-at-times', ch.at_specified_times);
|
||||
setBool('ch-t1-enabled', ch.time1_enabled);
|
||||
setBool('ch-t2-enabled', ch.time2_enabled);
|
||||
if (ch.time1_hour != null) qs2('ch-t1-hour').value = ch.time1_hour;
|
||||
if (ch.time1_min != null) qs2('ch-t1-min').value = ch.time1_min;
|
||||
if (ch.time2_hour != null) qs2('ch-t2-hour').value = ch.time2_hour;
|
||||
if (ch.time2_min != null) qs2('ch-t2-min').value = ch.time2_min;
|
||||
}
|
||||
|
||||
function clearCallHomeForm() {
|
||||
['ch-enabled','ch-after-event','ch-at-times','ch-t1-enabled','ch-t2-enabled']
|
||||
.forEach(id => { document.getElementById(id).selectedIndex = 0; });
|
||||
['ch-t1-hour','ch-t1-min','ch-t2-hour','ch-t2-min']
|
||||
.forEach(id => { document.getElementById(id).value = ''; });
|
||||
// Keep read-only display fields but clear them too
|
||||
['ch-dial-string','ch-num-retries','ch-retry-gap','ch-wait-conn','ch-warmup']
|
||||
.forEach(id => { document.getElementById(id).value = ''; });
|
||||
setChStatus('');
|
||||
}
|
||||
|
||||
async function readCallHome() {
|
||||
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
|
||||
setChStatus('Reading call home config from device…');
|
||||
document.getElementById('ch-read-btn').disabled = true;
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`);
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const ch = await r.json();
|
||||
populateCallHomeForm(ch);
|
||||
setChStatus('Call home config loaded from device.', 'ok');
|
||||
} catch(e) {
|
||||
setChStatus(`Read failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('ch-read-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCallHome() {
|
||||
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
|
||||
|
||||
// Build body — only include fields that have values
|
||||
const body = {};
|
||||
|
||||
function getBool(id) {
|
||||
const v = document.getElementById(id).value;
|
||||
return v === '' ? null : v === 'true';
|
||||
}
|
||||
function getIntField(id) {
|
||||
const v = document.getElementById(id).value.trim();
|
||||
return v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
|
||||
const en = getBool('ch-enabled');
|
||||
if (en !== null) body.auto_call_home_enabled = en;
|
||||
const ae = getBool('ch-after-event');
|
||||
if (ae !== null) body.after_event_recorded = ae;
|
||||
const at = getBool('ch-at-times');
|
||||
if (at !== null) body.at_specified_times = at;
|
||||
const t1e = getBool('ch-t1-enabled');
|
||||
if (t1e !== null) body.time1_enabled = t1e;
|
||||
const t1h = getIntField('ch-t1-hour');
|
||||
if (t1h !== null) body.time1_hour = t1h;
|
||||
const t1m = getIntField('ch-t1-min');
|
||||
if (t1m !== null) body.time1_min = t1m;
|
||||
const t2e = getBool('ch-t2-enabled');
|
||||
if (t2e !== null) body.time2_enabled = t2e;
|
||||
const t2h = getIntField('ch-t2-hour');
|
||||
if (t2h !== null) body.time2_hour = t2h;
|
||||
const t2m = getIntField('ch-t2-min');
|
||||
if (t2m !== null) body.time2_min = t2m;
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
setChStatus('No fields to write — change at least one field.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about value 3 in hour/min fields
|
||||
const hourMinFields = [body.time1_hour, body.time1_min, body.time2_hour, body.time2_min];
|
||||
if (hourMinFields.some(v => v === 3)) {
|
||||
setChStatus('Error: value 3 in hour/minute fields is not supported (DLE protocol limitation).', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldsStr = Object.keys(body).join(', ');
|
||||
setChStatus(`Writing ${Object.keys(body).length} field(s)…`);
|
||||
document.getElementById('ch-write-btn').disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
setChStatus(`Written: ${fieldsStr}`, 'ok');
|
||||
// Re-read to confirm changes
|
||||
await readCallHome();
|
||||
} catch(e) {
|
||||
setChStatus(`Write failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('ch-write-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────────
|
||||
function populateEventChips() {
|
||||
const el = document.getElementById('event-chips');
|
||||
@@ -1527,14 +1788,13 @@ function updatePeaksBar(ev) {
|
||||
|
||||
async function loadWaveform() {
|
||||
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
|
||||
const idx = currentEvent;
|
||||
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
|
||||
const idx = currentEvent;
|
||||
document.getElementById('load-btn').disabled = true;
|
||||
setStatus('Fetching waveform…', 'loading');
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
|
||||
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`);
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
data = await r.json();
|
||||
} catch(e) {
|
||||
@@ -1543,63 +1803,58 @@ async function loadWaveform() {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWaveformData = data;
|
||||
renderWaveform(data);
|
||||
document.getElementById('load-btn').disabled = false;
|
||||
}
|
||||
|
||||
// ── Shared waveform chart builder ──────────────────────────────────────────────
|
||||
// Renders waveform channel charts into chartsEl, destroys+replaces instances in
|
||||
// chartsStore. emptyEl (optional) is shown/hidden based on decoded sample count.
|
||||
function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
|
||||
function renderWaveform(data) {
|
||||
const sr = data.sample_rate || 1024;
|
||||
const pretrig = data.pretrig_samples || 0;
|
||||
const decoded = data.samples_decoded || 0;
|
||||
// Clip display to total_samples (pretrig + post_trig from compliance config).
|
||||
// The device bulk-streams zero-padded (0xFF = -1) frames beyond the configured
|
||||
// record window; without clipping these appear as a flat line at ~0 in/s past
|
||||
// the end of the actual recording. Confirmed 2026-04-15: a 36-frame 5A stream
|
||||
// for a 3.25s event (total_samples=3328) contained 19 trailing all-0xFF frames
|
||||
// (2457 extra samples) that caused a visible flat-line in the waveform display.
|
||||
const total = (data.total_samples && data.total_samples > 0) ? data.total_samples : decoded;
|
||||
const display = Math.min(decoded, total);
|
||||
const total = data.total_samples || decoded;
|
||||
const channels = data.channels || {};
|
||||
|
||||
// Destroy old chart instances
|
||||
Object.values(chartsStore).forEach(c => c.destroy());
|
||||
for (const k in chartsStore) delete chartsStore[k];
|
||||
// Status bar
|
||||
const bar = document.getElementById('status-bar');
|
||||
bar.innerHTML = '';
|
||||
bar.className = 'ok';
|
||||
const ts = data.timestamp;
|
||||
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
|
||||
addPill(`${data.record_type || '?'}`);
|
||||
addPill(`${sr} sps`);
|
||||
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
|
||||
addPill(`pretrig ${pretrig}`);
|
||||
addPill(`${data.rectime_seconds ?? '?'} s`);
|
||||
|
||||
if (decoded === 0) {
|
||||
if (emptyEl) {
|
||||
emptyEl.style.display = 'flex';
|
||||
const p = emptyEl.querySelector('p');
|
||||
if (p) p.textContent = data.record_type === 'Waveform'
|
||||
document.getElementById('empty-state').style.display = 'flex';
|
||||
document.getElementById('empty-state').querySelector('p').textContent =
|
||||
data.record_type === 'Waveform'
|
||||
? 'No samples decoded — check server logs'
|
||||
: `Record type "${data.record_type}" — waveform not supported yet`;
|
||||
}
|
||||
chartsEl.style.display = 'none';
|
||||
chartsEl.innerHTML = '';
|
||||
document.getElementById('charts').style.display = 'none';
|
||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const times = Array.from({length: display}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
chartsEl.style.display = 'flex';
|
||||
chartsEl.style.flexDirection = 'column';
|
||||
chartsEl.style.gap = '8px';
|
||||
chartsEl.innerHTML = '';
|
||||
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
const chartsDiv = document.getElementById('charts');
|
||||
chartsDiv.style.display = 'flex';
|
||||
chartsDiv.innerHTML = '';
|
||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
||||
|
||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||
|
||||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||
const samples = (channels[ch] || []).slice(0, display);
|
||||
if (samples.length === 0) continue;
|
||||
const samples = channels[ch];
|
||||
if (!samples || samples.length === 0) continue;
|
||||
|
||||
const isGeo = ch !== 'Mic';
|
||||
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
|
||||
|
||||
if (isGeo) {
|
||||
const scale = geoRange / 32767;
|
||||
const scale = geoAdcScale / 32767;
|
||||
plotData = samples.map(s => s * scale);
|
||||
// Use the device-recorded peak from the 0C waveform record — authoritative
|
||||
// and matches Blastware. Computing from raw samples can catch rogue
|
||||
@@ -1639,9 +1894,9 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
|
||||
const cw = document.createElement('div');
|
||||
cw.className = 'chart-canvas-wrap';
|
||||
const canvas = document.createElement('canvas');
|
||||
cw.appendChild(canvas); wrap.appendChild(cw); chartsEl.appendChild(wrap);
|
||||
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap);
|
||||
|
||||
chartsStore[ch] = new Chart(canvas, {
|
||||
charts[ch] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
|
||||
options: {
|
||||
@@ -1676,64 +1931,6 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderWaveform(data) {
|
||||
const sr = data.sample_rate || 1024;
|
||||
const pretrig = data.pretrig_samples || 0;
|
||||
const decoded = data.samples_decoded || 0;
|
||||
const total = data.total_samples || decoded;
|
||||
lastWaveformData = data;
|
||||
|
||||
// Status bar
|
||||
const bar = document.getElementById('status-bar');
|
||||
bar.innerHTML = '';
|
||||
bar.className = 'ok';
|
||||
const ts = data.timestamp;
|
||||
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
|
||||
addPill(`${data.record_type || '?'}`);
|
||||
addPill(`${sr} sps`);
|
||||
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
|
||||
addPill(`pretrig ${pretrig}`);
|
||||
addPill(`${data.rectime_seconds ?? '?'} s`);
|
||||
|
||||
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
|
||||
updateDebugPanel(data);
|
||||
}
|
||||
|
||||
// ── Debug panel population ─────────────────────────────────────────────────────
|
||||
function _fillDebugPanel(data, dbg, cont) {
|
||||
if (!dbg || !cont) return;
|
||||
const channels = data.channels || {};
|
||||
const pv = data.peak_values || {};
|
||||
const scale = geoRange / 32767;
|
||||
const geoChans = ['Tran', 'Vert', 'Long'];
|
||||
|
||||
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
|
||||
for (const ch of [...geoChans, 'Mic']) {
|
||||
const raw = (channels[ch] || []).slice(0, 8);
|
||||
if (raw.length === 0) continue;
|
||||
const maxAbs = Math.max(...raw.map(Math.abs));
|
||||
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
|
||||
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
|
||||
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
|
||||
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
|
||||
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
|
||||
if (nullPeaks.length > 0) {
|
||||
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
|
||||
}
|
||||
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
|
||||
|
||||
cont.innerHTML = html;
|
||||
dbg.style.display = 'block';
|
||||
}
|
||||
|
||||
function updateDebugPanel(data) {
|
||||
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
|
||||
}
|
||||
|
||||
// ── DB tabs ────────────────────────────────────────────────────────────────────
|
||||
let histLoaded = false;
|
||||
let unitsLoaded = false;
|
||||
@@ -1837,7 +2034,6 @@ async function loadHistory() {
|
||||
const pvs = ev.peak_vector_sum;
|
||||
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
||||
tr.innerHTML = `
|
||||
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform">〜</button></td>
|
||||
<td>${_fmtTs(ev.timestamp)}</td>
|
||||
<td class="td-key">${ev.serial ?? '—'}</td>
|
||||
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
|
||||
@@ -2012,86 +2208,9 @@ async function loadSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB waveform modal ─────────────────────────────────────────────────────────
|
||||
let modalCharts = {};
|
||||
|
||||
async function openDbWaveformModal(id) {
|
||||
const modal = document.getElementById('wf-modal');
|
||||
const titleEl = document.getElementById('wf-modal-title');
|
||||
const chartsEl = document.getElementById('wf-modal-charts');
|
||||
const emptyEl = document.getElementById('wf-modal-empty');
|
||||
const peaksEl = document.getElementById('wf-modal-peaks');
|
||||
const debugEl = document.getElementById('wf-modal-debug');
|
||||
|
||||
// Show modal in loading state
|
||||
titleEl.textContent = 'Loading…';
|
||||
peaksEl.classList.remove('visible');
|
||||
if (debugEl) debugEl.style.display = 'none';
|
||||
chartsEl.style.display = 'none';
|
||||
chartsEl.innerHTML = '';
|
||||
emptyEl.style.display = 'flex';
|
||||
emptyEl.querySelector('p').textContent = 'Loading waveform…';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
let data;
|
||||
try {
|
||||
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
data = await r.json();
|
||||
} catch(e) {
|
||||
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
|
||||
if (data.peak_values) {
|
||||
const pv = data.peak_values;
|
||||
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
|
||||
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
|
||||
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
|
||||
}
|
||||
|
||||
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
|
||||
const sr = data.sample_rate || 1024;
|
||||
const decoded = data.samples_decoded || 0;
|
||||
const total = data.total_samples || decoded;
|
||||
const pretrig = data.pretrig_samples || 0;
|
||||
let tsStr = '';
|
||||
if (data.timestamp) {
|
||||
const tsDisplay = typeof data.timestamp === 'object'
|
||||
? (data.timestamp.display || String(data.timestamp))
|
||||
: new Date(data.timestamp).toLocaleString();
|
||||
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
|
||||
}
|
||||
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
|
||||
|
||||
// Peaks bar
|
||||
const pv = data.peak_values || {};
|
||||
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
|
||||
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
|
||||
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
|
||||
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
|
||||
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
|
||||
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
|
||||
peaksEl.classList.add('visible');
|
||||
|
||||
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
|
||||
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
|
||||
}
|
||||
|
||||
function closeWfModal() {
|
||||
const modal = document.getElementById('wf-modal');
|
||||
if (!modal || modal.style.display === 'none') return;
|
||||
modal.style.display = 'none';
|
||||
// Destroy chart instances to free canvas memory
|
||||
Object.values(modalCharts).forEach(c => c.destroy());
|
||||
for (const k in modalCharts) delete modalCharts[k];
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
if (e.key === 'Escape') { closeWfModal(); return; }
|
||||
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
|
||||
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
|
||||
});
|
||||
@@ -2105,25 +2224,5 @@ document.getElementById('api-base').value = window.location.origin;
|
||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
|
||||
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
|
||||
<div id="wf-modal"
|
||||
style="display:none; position:fixed; inset:0; z-index:1000;
|
||||
background:rgba(1,4,9,0.88); align-items:flex-start;
|
||||
justify-content:center; padding:24px; overflow:auto;"
|
||||
onclick="if(event.target===this)closeWfModal()">
|
||||
<div style="background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:8px; width:100%; max-width:1100px;
|
||||
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
|
||||
|
||||
<!-- Header row -->
|
||||
<div style="display:flex; align-items:center; padding:10px 16px;
|
||||
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
|
||||
<div id="wf-modal-title"
|
||||
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
|
||||
—
|
||||
</div>
|
||||
<button onclick="closeWfModal()"
|
||||
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
|
||||
font-si
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+93
-257
@@ -175,27 +175,6 @@
|
||||
}
|
||||
#connect-btn:hover { background: #2ea043; }
|
||||
#connect-btn:disabled { background: #21262d; color: #484f58; }
|
||||
|
||||
#debug-panel {
|
||||
display: none;
|
||||
background: #0d1117;
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding: 6px 20px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: #6e7681;
|
||||
line-height: 1.7;
|
||||
}
|
||||
#debug-panel.visible { display: block; }
|
||||
#debug-panel .dp-row { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
#debug-panel .dp-ch { color: #8b949e; }
|
||||
#debug-panel .dp-ch span { color: #c9d1d9; }
|
||||
#debug-panel .dp-warn { color: #e3b341; }
|
||||
#debug-toggle {
|
||||
background: none; border: none; color: #484f58; font-size: 11px;
|
||||
cursor: pointer; padding: 0; float: right; text-decoration: underline;
|
||||
}
|
||||
#debug-toggle:hover { color: #8b949e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -214,12 +193,6 @@
|
||||
</div>
|
||||
<button id="connect-btn" onclick="connectUnit()">Connect</button>
|
||||
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
||||
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer"
|
||||
title="Re-download from device, bypassing server cache. Check this then click Load Waveform (or checking it will auto-reload if a waveform is already shown).">
|
||||
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
|
||||
onchange="if(this.checked && lastData !== null) loadWaveform()" />
|
||||
Force reload
|
||||
</label>
|
||||
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
||||
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
||||
</header>
|
||||
@@ -246,10 +219,6 @@
|
||||
</div>
|
||||
|
||||
<div id="status-bar">Ready — enter device host and click Connect.</div>
|
||||
<div id="debug-panel">
|
||||
<button id="debug-toggle" onclick="document.getElementById('debug-panel').classList.remove('visible')">hide</button>
|
||||
<div id="debug-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -271,50 +240,10 @@
|
||||
let charts = {};
|
||||
let lastData = null;
|
||||
let unitInfo = null;
|
||||
let geoRange = 10.0; // in/s full-scale for geo channels; updated on connect
|
||||
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;
|
||||
|
||||
// ── DB mode: opened via ?db_id=<uuid>&api_base=<url> from History tab ────────
|
||||
const _urlParams = new URLSearchParams(window.location.search);
|
||||
const _dbId = _urlParams.get('db_id');
|
||||
const _dbApiBase = (_urlParams.get('api_base') || '').replace(/\/$/, '');
|
||||
|
||||
async function _loadFromDb() {
|
||||
const apiBase = _dbApiBase || document.getElementById('api-base').value.replace(/\/$/, '');
|
||||
setStatus('Loading waveform from database…', 'loading');
|
||||
document.getElementById('unit-bar').style.display = 'none';
|
||||
// Hide live-device controls — not relevant in DB mode
|
||||
document.querySelector('header .conn-group').style.display = 'none';
|
||||
|
||||
const url = `${apiBase}/db/events/${encodeURIComponent(_dbId)}/waveform`;
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || resp.statusText);
|
||||
}
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
setStatus(`Error: ${e.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
lastData = data;
|
||||
renderWaveform(data);
|
||||
}
|
||||
|
||||
// Auto-load when opened with db_id param
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (_dbId) {
|
||||
// Pre-fill api-base if provided
|
||||
if (_dbApiBase) {
|
||||
document.getElementById('api-base').value = _dbApiBase;
|
||||
}
|
||||
_loadFromDb();
|
||||
}
|
||||
});
|
||||
|
||||
function setStatus(msg, cls = '') {
|
||||
const bar = document.getElementById('status-bar');
|
||||
bar.textContent = msg;
|
||||
@@ -349,7 +278,7 @@
|
||||
throw new Error(err.detail || resp.statusText);
|
||||
}
|
||||
unitInfo = await resp.json();
|
||||
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
|
||||
geoAdcScale = unitInfo.compliance_config?.geo_adc_scale ?? 10.0;
|
||||
} catch (e) {
|
||||
setStatus(`Error: ${e.message}`, 'error');
|
||||
btn.disabled = false;
|
||||
@@ -435,8 +364,7 @@
|
||||
btn.disabled = true;
|
||||
setStatus('Fetching waveform…', 'loading');
|
||||
|
||||
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
|
||||
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
|
||||
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||||
|
||||
let data;
|
||||
try {
|
||||
@@ -476,11 +404,8 @@
|
||||
bar.innerHTML = '';
|
||||
bar.className = 'ok';
|
||||
const ts = data.timestamp;
|
||||
const tsDisplay = ts
|
||||
? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
|
||||
: null;
|
||||
if (tsDisplay) {
|
||||
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
|
||||
if (ts) {
|
||||
bar.textContent = `Event #${data.index} — ${ts.display} `;
|
||||
} else {
|
||||
bar.textContent = `Event #${data.index} `;
|
||||
}
|
||||
@@ -488,14 +413,7 @@
|
||||
appendMeta('sr', `${sr} sps`);
|
||||
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
||||
appendMeta('pretrig', pretrig);
|
||||
// rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
|
||||
// _decode_a5_waveform. Also show the compliance config record_time for reference.
|
||||
const cfgRt = unitInfo?.compliance_config?.record_time;
|
||||
const strtRt = data.rectime_seconds;
|
||||
const rtStr = (strtRt !== null && strtRt !== undefined)
|
||||
? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
|
||||
: (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
|
||||
appendMeta('rectime', rtStr);
|
||||
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
|
||||
|
||||
// No waveform data — show a clear reason instead of empty charts
|
||||
if (decoded === 0) {
|
||||
@@ -505,20 +423,14 @@
|
||||
? 'Waveform decode returned no samples — check server logs'
|
||||
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
|
||||
document.getElementById('charts').style.display = 'none';
|
||||
document.getElementById('debug-panel').classList.remove('visible');
|
||||
Object.values(charts).forEach(c => c.destroy());
|
||||
charts = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Clip to total_samples to exclude zero-padding the device appends beyond
|
||||
// the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
|
||||
// decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
|
||||
const displayCount = (total > 0 && total < decoded) ? total : decoded;
|
||||
|
||||
// Build time axis in seconds (matching Blastware event report layout).
|
||||
const times = Array.from({ length: displayCount }, (_, i) =>
|
||||
((i - pretrig) / sr).toFixed(3)
|
||||
// Build time axis (ms)
|
||||
const times = Array.from({ length: decoded }, (_, i) =>
|
||||
((i - pretrig) / sr * 1000).toFixed(2)
|
||||
);
|
||||
|
||||
// Show charts area
|
||||
@@ -535,15 +447,6 @@
|
||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
|
||||
|
||||
// 0C record peak values (device-computed, authoritative) per channel.
|
||||
// Keys: live-device endpoint uses tran_in_s/vert_in_s/long_in_s;
|
||||
// DB blobs created before 2026-04-14 used tran/vert/long — fall back for compat.
|
||||
const peakValues0C = {
|
||||
Tran: data.peak_values?.tran_in_s ?? data.peak_values?.tran ?? null,
|
||||
Vert: data.peak_values?.vert_in_s ?? data.peak_values?.vert ?? null,
|
||||
Long: data.peak_values?.long_in_s ?? data.peak_values?.long ?? null,
|
||||
};
|
||||
|
||||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||
const samples = channels[ch];
|
||||
if (!samples || samples.length === 0) continue;
|
||||
@@ -552,38 +455,22 @@
|
||||
const isGeo = ch !== 'Mic';
|
||||
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
|
||||
|
||||
// Clip channel samples to displayCount (same as time axis)
|
||||
const clippedSamples = samples.length > displayCount
|
||||
? samples.slice(0, displayCount)
|
||||
: samples;
|
||||
|
||||
// peak0C declared here (function scope) so it is visible in the Chart.js
|
||||
// config block below (which lives outside the if(isGeo) block).
|
||||
let peak0C = null;
|
||||
|
||||
if (isGeo) {
|
||||
// Geo channels: counts × (range / 32767) → in/s
|
||||
// Scale factor for the waveform shape (may need calibration per unit)
|
||||
const scale = geoRange / 32767;
|
||||
plotSamples = clippedSamples.map(c => c * scale);
|
||||
|
||||
// Use the device-computed 0C record peak for the label (authoritative).
|
||||
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
|
||||
peak0C = peakValues0C[ch];
|
||||
const peakIns = (peak0C !== null && peak0C !== undefined)
|
||||
? peak0C
|
||||
: Math.max(...plotSamples.map(Math.abs));
|
||||
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(...clippedSamples.map(Math.abs));
|
||||
const peakCounts = Math.max(...samples.map(Math.abs));
|
||||
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
||||
? Math.abs(micPeakPsi) / peakCounts
|
||||
: 1.0;
|
||||
plotSamples = clippedSamples.map(c => c * micScale);
|
||||
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)`;
|
||||
@@ -617,139 +504,88 @@
|
||||
renderData = plotSamples.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
let chart;
|
||||
try {
|
||||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: renderTimes,
|
||||
datasets: [{
|
||||
data: renderData,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: items => `t = ${items[0].label} s`,
|
||||
label: item => tooltipFmt(item.raw),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
ticks: {
|
||||
color: '#484f58',
|
||||
maxTicksLimit: 10,
|
||||
maxRotation: 0,
|
||||
callback: (val, i) => renderTimes[i] + ' s',
|
||||
},
|
||||
grid: { color: '#21262d' },
|
||||
},
|
||||
y: {
|
||||
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
|
||||
// decode artifacts (which inflate autoscale to full range) don't
|
||||
// squash the actual blast signal into an invisible flat line.
|
||||
// The 0C peak value is authoritative for the true signal amplitude.
|
||||
// Guard: only apply if peak0C is a valid finite positive number.
|
||||
...(isGeo && peak0C !== null && peak0C !== undefined
|
||||
&& isFinite(peak0C) && peak0C > 0 ? {
|
||||
min: -(peak0C * 1.4),
|
||||
max: (peak0C * 1.4),
|
||||
} : {}),
|
||||
ticks: {
|
||||
color: '#484f58',
|
||||
maxTicksLimit: 5,
|
||||
callback: v => tickFmt(v),
|
||||
},
|
||||
grid: { color: '#21262d' },
|
||||
title: {
|
||||
display: true,
|
||||
text: yUnit,
|
||||
color: '#484f58',
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [{
|
||||
// Draw trigger line at t=0
|
||||
id: 'triggerLine',
|
||||
afterDraw(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const xAxis = chart.scales.x;
|
||||
const yAxis = chart.scales.y;
|
||||
|
||||
// Find index of the trigger point (t ≥ 0.000 s)
|
||||
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
|
||||
if (zeroIdx < 0) return;
|
||||
|
||||
const x = xAxis.getPixelForValue(zeroIdx);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yAxis.top);
|
||||
ctx.lineTo(x, yAxis.bottom);
|
||||
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
const chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: renderTimes,
|
||||
datasets: [{
|
||||
data: renderData,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0,
|
||||
}],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Chart.js error for channel ${ch}:`, err);
|
||||
canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
||||
if (chart) charts[ch] = chart;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ── Debug panel: raw ADC counts + decode diagnostics ────────────────────
|
||||
// Shows the first 8 decoded ADC counts per channel and whether peak values
|
||||
// came from the 0C record (authoritative) or from Math.max fallback.
|
||||
// Useful for diagnosing channel misalignment without touching server logs.
|
||||
const dbg = document.getElementById('debug-panel');
|
||||
const dbgContent = document.getElementById('debug-content');
|
||||
const geoChans = ['Tran', 'Vert', 'Long'];
|
||||
const rawChans = channels;
|
||||
const scale = geoRange / 32767;
|
||||
|
||||
let dbgHtml = '<div class="dp-row">';
|
||||
|
||||
// per-channel first-8 raw counts
|
||||
for (const ch of [...geoChans, 'Mic']) {
|
||||
const raw = (rawChans[ch] || []).slice(0, 8);
|
||||
if (raw.length === 0) continue;
|
||||
const maxAbs = Math.max(...raw.map(Math.abs));
|
||||
const p0c = peakValues0C?.[ch] ?? null;
|
||||
const src = (ch !== 'Mic' && p0c !== null) ? `0C=${p0c.toFixed(4)}` : `Math.max=${(maxAbs*scale).toFixed(4)}`;
|
||||
dbgHtml += `<div class="dp-ch">${ch} raw[0:8]: <span>${raw.join(', ')}</span> peak src: <span>${src}</span></div>`;
|
||||
}
|
||||
dbgHtml += '</div>';
|
||||
|
||||
// warn if peak0C was null for any geo channel
|
||||
const nullPeaks = geoChans.filter(ch => (peakValues0C?.[ch] ?? null) === null);
|
||||
if (nullPeaks.length > 0) {
|
||||
dbgHtml += `<div class="dp-warn">⚠ peak0C null for: ${nullPeaks.join(', ')} — using Math.max fallback (check Force reload + Load Waveform)</div>`;
|
||||
}
|
||||
|
||||
// summary line
|
||||
dbgHtml += `<div>decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange}</div>`;
|
||||
|
||||
dbgContent.innerHTML = dbgHtml;
|
||||
dbg.classList.add('visible');
|
||||
}
|
||||
|
||||
// Auto-detect API base from wherever this page was served from
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user