Compare commits
11 Commits
dev
...
6a0f0ae2e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a0f0ae2e4 | |||
| bbd574e7d5 | |||
| 727bfed5c4 | |||
| 8d0537389d | |||
| c5a7914032 | |||
| dbb9febe2c | |||
| 9ae968b108 | |||
| 171dc2551c | |||
| 4f4c1a8f64 | |||
| 0da88ec6aa | |||
| edb4698bfb |
@@ -163,6 +163,83 @@ record — 5A remains the sole source for those fields and they are set uncondit
|
|||||||
`stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears,
|
`stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears,
|
||||||
then sends the termination frame.
|
then sends the termination frame.
|
||||||
|
|
||||||
|
### SUB 5A — STRT record layout and rectime_seconds (CORRECTED 2026-04-14)
|
||||||
|
|
||||||
|
The STRT record is 21 bytes embedded at the start of A5[0] data. Offsets relative to
|
||||||
|
the `b'STRT'` magic bytes:
|
||||||
|
|
||||||
|
```
|
||||||
|
+0..3 b'STRT' magic
|
||||||
|
+4..5 flags 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
|
||||||
|
+6..9 next_key4 ← key of the NEXT stored event (NOT the current event) ← confirmed 2026-04-14
|
||||||
|
+10..13 prev_key4 ← key of the PREVIOUS stored event ← confirmed 2026-04-14
|
||||||
|
+14..15 UNKNOWN (values seen: 0xDA63=55907, 0xF38F=62351, 0x5685=22149) — NOT total_samples
|
||||||
|
+16..17 UNKNOWN (values seen: 0x0122=290, 0x011A=282, 0x00FA=250) — NOT pretrig_samples
|
||||||
|
+18 uint8 record-MODE byte — NOT rectime in seconds
|
||||||
|
+19..20 0x00 0x00
|
||||||
|
```
|
||||||
|
|
||||||
|
**CONFIRMED field values (2026-04-14) from 3 desk-thump events, firmware S338.17:**
|
||||||
|
|
||||||
|
| Field | What it is | Confirmed |
|
||||||
|
|---|---|---|
|
||||||
|
| +4..5 | 0xFFFE single-shot / 0xFFFD continuous | ✅ |
|
||||||
|
| +6..9 | next_event_key (NOT current) | ✅ 3 events |
|
||||||
|
| +10..13 | prev_event_key | ✅ 3 events |
|
||||||
|
| +18 | mode byte: 0x46 ('F') = single-shot, 0x0E = continuous | ✅ |
|
||||||
|
|
||||||
|
**CONFIRMED (2026-04-14) — total_samples and pretrig_samples are NOT stored in the STRT record.**
|
||||||
|
The prior documented offsets (+14..15 for total_samples, +16..17 for pretrig_samples) were
|
||||||
|
WRONG — confirmed by cross-checking STRT-derived rectime against compliance record_time
|
||||||
|
(4-14-26): all 4 events give STRT-derived rectime of 21–61 s vs actual 3.0 s (ratio 7–20×).
|
||||||
|
Extending the STRT dump to 32 bytes confirmed that bytes 21+ are the start of the raw ADC
|
||||||
|
waveform samples, not more STRT fields. Blastware itself derives total_samples and
|
||||||
|
pretrig_samples from the compliance config — exactly what our fallback does.
|
||||||
|
|
||||||
|
**The compliance-config fallback IS the correct permanent solution, not a workaround.**
|
||||||
|
`_decode_a5_waveform` uses:
|
||||||
|
- `pretrig_samples = round(0.25 × sample_rate)` (compliance monitoring standard)
|
||||||
|
- `total_samples = pretrig_samples + round(record_time × sample_rate)`
|
||||||
|
|
||||||
|
**CONFIRMED (2026-04-14) — waveform starts at strt_pos + 21 (no preamble).**
|
||||||
|
The original `sp + 27` skip (STRT 21B + null-pad 2B + 0xFF-sentinel 4B) was WRONG.
|
||||||
|
The 6-byte "preamble" in the 4-2-26 blast capture (`00 00 ff ff ff ff`) was actually the
|
||||||
|
first ~0.75 sample-sets of quiet pre-trigger ADC data misread as padding. Desk-thump
|
||||||
|
events show different bytes at positions 21-26 (e.g. `00 10 02 00 ff fc`) — they are real
|
||||||
|
ADC readings, not a fixed preamble. The `sp + 27` skip discarded 6 bytes of real waveform
|
||||||
|
data and misaligned the channel decode for all subsequent frames. Fixed: `wave = w[sp+21:]`.
|
||||||
|
|
||||||
|
The +6..9 next_key and +10..13 prev_key fields are confirmed across 4 events including the
|
||||||
|
first-event-after-erase case (prev_key = self-reference `01110000`; next_key = device
|
||||||
|
pre-allocates the predicted next slot even before any second event exists).
|
||||||
|
|
||||||
|
**CRITICAL — strt[18] is a record-mode byte, NOT rectime_seconds (confirmed 2026-04-14):**
|
||||||
|
Analysis of 15 distinct STRT records across the 4-9-26 ACH capture shows:
|
||||||
|
- `flags=0xFFFE` (single-shot) → `strt[18] = 0x46` ('F') for EVERY event regardless of duration
|
||||||
|
- `flags=0xFFFD` (continuous) → `strt[18] = 0x0E` for EVERY event regardless of duration
|
||||||
|
|
||||||
|
Do NOT use `strt[18]` for rectime.
|
||||||
|
|
||||||
|
**Pre-trigger time is separate from record_time (confirmed 2026-04-14):**
|
||||||
|
Blastware documentation states: "The default Time Scale is -0.25 second to 1 second — this
|
||||||
|
negative number accounts for the pre-trigger set for compliance monitoring." Therefore:
|
||||||
|
- `record_time` (3.0 s) is POST-TRIGGER duration only
|
||||||
|
- Pre-trigger = 0.25 s = 256 samples at 1024 sps (compliance monitoring standard default)
|
||||||
|
- The pre-trigger field has NOT yet been located in the raw compliance config bytes
|
||||||
|
- `_decode_a5_waveform` falls back to pretrig = 0.25 × sr from compliance standard
|
||||||
|
- TODO: locate pretrig_time offset in ComplianceConfig — search around anchor or channel blocks
|
||||||
|
|
||||||
|
The device bulk-streams zero-padded frames BEYOND the configured record window. The
|
||||||
|
viewer clips `displayCount = total_samples = pretrig + post_trig` to exclude this padding.
|
||||||
|
|
||||||
|
**Validity checks in `_decode_a5_waveform`:**
|
||||||
|
Check 1: `pretrig_samples >= total_samples` → invalid (original check).
|
||||||
|
Check 2: STRT-derived rectime differs from `compliance_config.record_time` by more than 2×
|
||||||
|
→ invalid. Both failures fall back to the compliance-config derived values.
|
||||||
|
`_decode_a5_waveform` logs `raw strt[0:21]` at WARNING level on any failure.
|
||||||
|
Observed once (2026-04-14) with `strt[16:18] = 0x41 0x01` → pretrig=16641 (impossible).
|
||||||
|
Root cause not yet identified — capture the warning log hex dump to diagnose.
|
||||||
|
|
||||||
### SUB 5A — end-of-stream signal (confirmed 2026-04-06)
|
### SUB 5A — end-of-stream signal (confirmed 2026-04-06)
|
||||||
|
|
||||||
After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
|
After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
|
||||||
|
|||||||
@@ -1,268 +1,268 @@
|
|||||||
# seismo-relay `v0.12.0`
|
# seismo-relay `v0.12.0`
|
||||||
|
|
||||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||||
software for managing MiniMate Plus seismographs.
|
software for managing MiniMate Plus seismographs.
|
||||||
|
|
||||||
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
||||||
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
||||||
|
|
||||||
> **Status:** Active development. Full read + write + erase + monitoring
|
> **Status:** Active development. Full read + write + erase + monitoring
|
||||||
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
||||||
> handles inbound unit connections, downloads events, and persists everything
|
> handles inbound unit connections, downloads events, and persists everything
|
||||||
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
||||||
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What's in here
|
## What's in here
|
||||||
|
|
||||||
```
|
```
|
||||||
seismo-relay/
|
seismo-relay/
|
||||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||||
│
|
│
|
||||||
├── minimateplus/ ← MiniMate Plus client library
|
├── minimateplus/ ← MiniMate Plus client library
|
||||||
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
||||||
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
||||||
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
||||||
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
||||||
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
||||||
│
|
│
|
||||||
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
||||||
│ ├── server.py ← All device + DB endpoints
|
│ ├── server.py ← All device + DB endpoints
|
||||||
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
||||||
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
||||||
│
|
│
|
||||||
├── bridges/
|
├── bridges/
|
||||||
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
||||||
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
||||||
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
||||||
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
||||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||||
│ └── raw_capture.py ← Simple raw capture tool
|
│ └── raw_capture.py ← Simple raw capture tool
|
||||||
│
|
│
|
||||||
├── parsers/
|
├── parsers/
|
||||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||||
│ └── frame_db.py ← SQLite frame database
|
│ └── frame_db.py ← SQLite frame database
|
||||||
│
|
│
|
||||||
└── docs/
|
└── docs/
|
||||||
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
### ACH inbound server (production)
|
### ACH inbound server (production)
|
||||||
|
|
||||||
Listens for inbound unit call-homes, downloads all new events and monitor log
|
Listens for inbound unit call-homes, downloads all new events and monitor log
|
||||||
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python bridges/ach_server.py --port 12345 --output bridges/captures/
|
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`.
|
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
```
|
```
|
||||||
--port N Listen port (default 12345)
|
--port N Listen port (default 12345)
|
||||||
--output DIR Capture directory (default bridges/captures/)
|
--output DIR Capture directory (default bridges/captures/)
|
||||||
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
||||||
--max-events N Safety cap for first run (default: unlimited)
|
--max-events N Safety cap for first run (default: unlimited)
|
||||||
--clear-after-download Erase device memory after successful download
|
--clear-after-download Erase device memory after successful download
|
||||||
--verbose Debug logging
|
--verbose Debug logging
|
||||||
```
|
```
|
||||||
|
|
||||||
### SFM REST server
|
### SFM REST server
|
||||||
|
|
||||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python sfm/server.py # default: 0.0.0.0:8200
|
python sfm/server.py # default: 0.0.0.0:8200
|
||||||
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
|
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`
|
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
|
||||||
for the interactive API docs.
|
for the interactive API docs.
|
||||||
|
|
||||||
### Seismo Lab GUI
|
### Seismo Lab GUI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python seismo_lab.py
|
python seismo_lab.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SFM REST API
|
## SFM REST API
|
||||||
|
|
||||||
### Live device endpoints
|
### Live device endpoints
|
||||||
|
|
||||||
Each call dials the device, does its work, and closes the connection. TCP
|
Each call dials the device, does its work, and closes the connection. TCP
|
||||||
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
||||||
|
|
||||||
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
||||||
redundant TCP round-trips:
|
redundant TCP round-trips:
|
||||||
|
|
||||||
| Method | URL | Cache |
|
| Method | URL | Cache |
|
||||||
|--------|-----|-------|
|
|--------|-----|-------|
|
||||||
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
|
| `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/events` | Count-probe fast path (~2s); full download only when new events detected |
|
||||||
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
|
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
|
||||||
| `GET` | `/device/monitor/status` | 30-second TTL |
|
| `GET` | `/device/monitor/status` | 30-second TTL |
|
||||||
| `POST` | `/device/connect` | — |
|
| `POST` | `/device/connect` | — |
|
||||||
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
||||||
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
||||||
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
||||||
|
|
||||||
All cached endpoints accept `?force=true` to bypass the cache.
|
All cached endpoints accept `?force=true` to bypass the cache.
|
||||||
|
|
||||||
Transport query params (supply one set):
|
Transport query params (supply one set):
|
||||||
```
|
```
|
||||||
Serial: ?port=COM5&baud=38400
|
Serial: ?port=COM5&baud=38400
|
||||||
TCP: ?host=1.2.3.4&tcp_port=12345
|
TCP: ?host=1.2.3.4&tcp_port=12345
|
||||||
```
|
```
|
||||||
|
|
||||||
### DB read endpoints
|
### DB read endpoints
|
||||||
|
|
||||||
Query the SQLite database written by `ach_server.py`. All read-only except
|
Query the SQLite database written by `ach_server.py`. All read-only except
|
||||||
`PATCH /db/events/{id}/false_trigger`.
|
`PATCH /db/events/{id}/false_trigger`.
|
||||||
|
|
||||||
| Method | URL | Description |
|
| Method | URL | Description |
|
||||||
|--------|-----|-------------|
|
|--------|-----|-------------|
|
||||||
| `GET` | `/db/units` | All known serials with summary stats |
|
| `GET` | `/db/units` | All known serials with summary stats |
|
||||||
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
||||||
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
||||||
| `GET` | `/db/sessions` | ACH call-home session history |
|
| `GET` | `/db/sessions` | ACH call-home session history |
|
||||||
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## minimateplus library
|
## minimateplus library
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from minimateplus import MiniMateClient
|
from minimateplus import MiniMateClient
|
||||||
from minimateplus.transport import TcpTransport
|
from minimateplus.transport import TcpTransport
|
||||||
|
|
||||||
# Serial
|
# Serial
|
||||||
client = MiniMateClient(port="COM5")
|
client = MiniMateClient(port="COM5")
|
||||||
|
|
||||||
# TCP (cellular modem)
|
# TCP (cellular modem)
|
||||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||||
|
|
||||||
with client:
|
with client:
|
||||||
# Read
|
# Read
|
||||||
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
||||||
count = client.count_events() # Number of stored events
|
count = client.count_events() # Number of stored events
|
||||||
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
||||||
events = client.get_events() # Full download: headers + peaks + metadata
|
events = client.get_events() # Full download: headers + peaks + metadata
|
||||||
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
||||||
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
||||||
|
|
||||||
# Write
|
# Write
|
||||||
client.apply_config(
|
client.apply_config(
|
||||||
sample_rate=1024,
|
sample_rate=1024,
|
||||||
trigger_level_geo=0.5,
|
trigger_level_geo=0.5,
|
||||||
project="Bridge Inspection 2026",
|
project="Bridge Inspection 2026",
|
||||||
client_name="City of Portland",
|
client_name="City of Portland",
|
||||||
operator="B. Harrison",
|
operator="B. Harrison",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Control
|
# Control
|
||||||
client.start_monitoring() # SUB 0x96
|
client.start_monitoring() # SUB 0x96
|
||||||
client.stop_monitoring() # SUB 0x97
|
client.stop_monitoring() # SUB 0x97
|
||||||
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
||||||
```
|
```
|
||||||
|
|
||||||
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
|
`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
|
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
|
||||||
existed at record time — not backfilled from the current compliance config.
|
existed at record time — not backfilled from the current compliance config.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
||||||
Three tables, all unit-keyed by serial number:
|
Three tables, all unit-keyed by serial number:
|
||||||
|
|
||||||
| Table | Key | Contents |
|
| Table | Key | Contents |
|
||||||
|-------|-----|----------|
|
|-------|-----|----------|
|
||||||
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
|
| `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 |
|
| `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 |
|
| `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
|
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
|
||||||
never produce duplicate rows. Post-erase key reuse is handled automatically
|
never produce duplicate rows. Post-erase key reuse is handled automatically
|
||||||
via the high-water mark in `ach_state.json`.
|
via the high-water mark in `ach_state.json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Connecting over cellular (RV50 / RV55)
|
## Connecting over cellular (RV50 / RV55)
|
||||||
|
|
||||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
||||||
|
|
||||||
### Required ACEmanager settings
|
### Required ACEmanager settings
|
||||||
|
|
||||||
| Setting | Value | Why |
|
| Setting | Value | Why |
|
||||||
|---------|-------|-----|
|
|---------|-------|-----|
|
||||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||||
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
||||||
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
||||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
||||||
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
||||||
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
||||||
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Protocol quick-reference
|
## Protocol quick-reference
|
||||||
|
|
||||||
| Term | Value | Meaning |
|
| Term | Value | Meaning |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| DLE | `0x10` | Data Link Escape |
|
| DLE | `0x10` | Data Link Escape |
|
||||||
| STX | `0x02` | Start of frame |
|
| STX | `0x02` | Start of frame |
|
||||||
| ETX | `0x03` | End of frame |
|
| ETX | `0x03` | End of frame |
|
||||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||||
|
|
||||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||||
|
|
||||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyserial fastapi uvicorn
|
pip install pyserial fastapi uvicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||||
Windows (check "tcl/tk and IDLE" during install).
|
Windows (check "tcl/tk and IDLE" during install).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Virtual COM ports (bridge capture)
|
## Virtual COM ports (bridge capture)
|
||||||
|
|
||||||
```
|
```
|
||||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||||
```
|
```
|
||||||
|
|
||||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
|
- [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] Write commands — push compliance config, trigger thresholds, project strings to device
|
||||||
- [x] Erase all events — confirmed erase sequence from live MITM capture
|
- [x] Erase all events — confirmed erase sequence from live MITM capture
|
||||||
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
||||||
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
|
- [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] 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] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
|
||||||
- [x] SFM REST API — device control + DB query endpoints, live device cache
|
- [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
|
- [ ] 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)
|
- [ ] 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
|
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
|
||||||
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||||
|
|||||||
+838
-777
File diff suppressed because it is too large
Load Diff
+155
-34
@@ -448,7 +448,7 @@ class MiniMateClient:
|
|||||||
proto.confirm_erase_all()
|
proto.confirm_erase_all()
|
||||||
log.info("delete_all_events: erase confirmed — device memory cleared")
|
log.info("delete_all_events: erase confirmed — device memory cleared")
|
||||||
|
|
||||||
def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None) -> list[Event]:
|
def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None, compliance_config: Optional["ComplianceConfig"] = None) -> list[Event]:
|
||||||
"""
|
"""
|
||||||
Download all stored events from the device using the confirmed
|
Download all stored events from the device using the confirmed
|
||||||
1E → 0A → 0C → 5A → 1F event-iterator protocol.
|
1E → 0A → 0C → 5A → 1F event-iterator protocol.
|
||||||
@@ -479,6 +479,7 @@ class MiniMateClient:
|
|||||||
ProtocolError: on unrecoverable communication failure.
|
ProtocolError: on unrecoverable communication failure.
|
||||||
"""
|
"""
|
||||||
proto = self._require_proto()
|
proto = self._require_proto()
|
||||||
|
_compliance_config = compliance_config # passed through to _decode_a5_waveform
|
||||||
|
|
||||||
log.info("get_events: requesting first event (SUB 1E)")
|
log.info("get_events: requesting first event (SUB 1E)")
|
||||||
try:
|
try:
|
||||||
@@ -603,12 +604,12 @@ class MiniMateClient:
|
|||||||
"get_events: 5A full waveform download for key=%s", cur_key.hex()
|
"get_events: 5A full waveform download for key=%s", cur_key.hex()
|
||||||
)
|
)
|
||||||
a5_frames = proto.read_bulk_waveform_stream(
|
a5_frames = proto.read_bulk_waveform_stream(
|
||||||
cur_key, stop_after_metadata=False, max_chunks=128
|
cur_key, stop_after_metadata=False, max_chunks=2048
|
||||||
)
|
)
|
||||||
if a5_frames:
|
if a5_frames:
|
||||||
a5_ok = True
|
a5_ok = True
|
||||||
_decode_a5_metadata_into(a5_frames, ev)
|
_decode_a5_metadata_into(a5_frames, ev)
|
||||||
_decode_a5_waveform(a5_frames, ev)
|
_decode_a5_waveform(a5_frames, ev, compliance_config=_compliance_config)
|
||||||
log.info(
|
log.info(
|
||||||
"get_events: 5A decoded %d sample-sets",
|
"get_events: 5A decoded %d sample-sets",
|
||||||
len((ev.raw_samples or {}).get("Tran", [])),
|
len((ev.raw_samples or {}).get("Tran", [])),
|
||||||
@@ -1311,6 +1312,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
|
|||||||
def _decode_a5_waveform(
|
def _decode_a5_waveform(
|
||||||
frames_data: list[bytes],
|
frames_data: list[bytes],
|
||||||
event: Event,
|
event: Event,
|
||||||
|
compliance_config: Optional["ComplianceConfig"] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Decode the raw 4-channel ADC waveform from a complete set of SUB 5A
|
Decode the raw 4-channel ADC waveform from a complete set of SUB 5A
|
||||||
@@ -1336,15 +1338,22 @@ def _decode_a5_waveform(
|
|||||||
|
|
||||||
── Frame structure ──────────────────────────────────────────────────────────
|
── Frame structure ──────────────────────────────────────────────────────────
|
||||||
A5[0] (probe response):
|
A5[0] (probe response):
|
||||||
db[7:] = [11-byte header] [21-byte STRT record] [6-byte preamble] [waveform ...]
|
db[7:] = [11-byte header] [21-byte STRT record] [waveform ...]
|
||||||
STRT: b'STRT' at offset 11, total 21 bytes
|
STRT: b'STRT' at offset 11, total 21 bytes
|
||||||
+8 uint16 BE: total_samples (expected full-record sample-sets)
|
+4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
|
||||||
+16 uint16 BE: pretrig_samples (pre-trigger sample count)
|
+6..9 next_event_key4 (device pre-allocates next slot)
|
||||||
+18 uint8: rectime_seconds (record duration)
|
+10..13 prev_event_key4
|
||||||
Preamble: 6 bytes after the STRT record (confirmed from 4-2-26 blast capture):
|
+14..15 UNKNOWN (not total_samples)
|
||||||
bytes 21-22: 0x00 0x00 (null padding)
|
+16..17 UNKNOWN (not pretrig_samples)
|
||||||
bytes 23-26: 0xFF × 4 (sync sentinel / alignment marker)
|
+18 mode byte: 0x46 single-shot, 0x0E continuous
|
||||||
Waveform starts at strt_pos + 27 within db[7:].
|
+19..20 0x00 0x00
|
||||||
|
Waveform starts immediately at strt_pos + 21 (no preamble).
|
||||||
|
NOTE: The original 4-2-26 blast capture appeared to show a 6-byte
|
||||||
|
"preamble" (00 00 ff ff ff ff) after the STRT record, but this was
|
||||||
|
actually the first ~0.75 sample-sets of quiet pre-trigger ADC data
|
||||||
|
misread as padding. Confirmed 2026-04-14: bytes 21+ are raw waveform.
|
||||||
|
total_samples and pretrig_samples are NOT stored in the STRT record;
|
||||||
|
they are derived from the compliance config (the correct permanent source).
|
||||||
|
|
||||||
A5[1..N] (chunk responses):
|
A5[1..N] (chunk responses):
|
||||||
db[7:] = [8-byte per-frame header] [waveform bytes ...]
|
db[7:] = [8-byte per-frame header] [waveform bytes ...]
|
||||||
@@ -1357,11 +1366,13 @@ def _decode_a5_waveform(
|
|||||||
cumulative global byte offset; at each new frame, the starting alignment
|
cumulative global byte offset; at each new frame, the starting alignment
|
||||||
within the T,V,L,M cycle is (global_offset % 8).
|
within the T,V,L,M cycle is (global_offset % 8).
|
||||||
|
|
||||||
Confirmed sizes from 4-2-26 (A5[0..8], skipping A5[7] metadata frame
|
Confirmed sizes from 4-2-26 blast capture (A5[0..8], metadata at A5[7]):
|
||||||
and A5[9] terminator):
|
|
||||||
Frame 0: 934B Frame 1: 963B Frame 2: 946B Frame 3: 960B
|
Frame 0: 934B Frame 1: 963B Frame 2: 946B Frame 3: 960B
|
||||||
Frame 4: 952B Frame 5: 946B Frame 6: 941B Frame 8: 992B
|
Frame 4: 952B Frame 5: 946B Frame 6: 941B Frame 8: 992B
|
||||||
— none are multiples of 8.
|
— none are multiples of 8.
|
||||||
|
NOTE: Metadata frame position is variable — at fi==7 for blast events
|
||||||
|
(4-2-26 capture) and fi==6 for desk-thump events (2026-04-14 confirmed).
|
||||||
|
The dynamic b"Project:" detection handles both cases.
|
||||||
|
|
||||||
── Modifies event in-place. ─────────────────────────────────────────────────
|
── Modifies event in-place. ─────────────────────────────────────────────────
|
||||||
"""
|
"""
|
||||||
@@ -1378,27 +1389,48 @@ def _decode_a5_waveform(
|
|||||||
|
|
||||||
# STRT record layout (21 bytes, offsets relative to b'STRT'):
|
# STRT record layout (21 bytes, offsets relative to b'STRT'):
|
||||||
# +0..3 magic b'STRT'
|
# +0..3 magic b'STRT'
|
||||||
# +8..9 uint16 BE total_samples (full-record expected sample-set count)
|
# +4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
|
||||||
# +16..17 uint16 BE pretrig_samples
|
# +6..9 next_event_key4 (NOT current key — device pre-allocates next)
|
||||||
# +18 uint8 rectime_seconds
|
# +10..13 prev_event_key4
|
||||||
|
# +14..15 UNKNOWN — confirmed NOT total_samples (confirmed 2026-04-14)
|
||||||
|
# +16..17 UNKNOWN — confirmed NOT pretrig_samples (confirmed 2026-04-14)
|
||||||
|
# +18 mode byte: 0x46='F' single-shot, 0x0E continuous — NOT rectime
|
||||||
|
# +19..20 0x00 0x00
|
||||||
|
# Bytes 21+ are raw ADC waveform samples — no preamble.
|
||||||
|
# total_samples / pretrig_samples are NOT stored in STRT at all.
|
||||||
|
# The compliance config fallback is the correct permanent source.
|
||||||
strt = w0[strt_pos : strt_pos + 21]
|
strt = w0[strt_pos : strt_pos + 21]
|
||||||
if len(strt) < 21:
|
if len(strt) < 21:
|
||||||
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
|
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
|
||||||
return
|
return
|
||||||
|
|
||||||
total_samples = struct.unpack_from(">H", strt, 8)[0]
|
log.info(
|
||||||
pretrig_samples = struct.unpack_from(">H", strt, 16)[0]
|
"_decode_a5_waveform: STRT raw[0:21]: %s",
|
||||||
rectime_seconds = strt[18]
|
strt.hex(' '),
|
||||||
|
|
||||||
event.total_samples = total_samples
|
|
||||||
event.pretrig_samples = pretrig_samples
|
|
||||||
event.rectime_seconds = rectime_seconds
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"_decode_a5_waveform: STRT total_samples=%d pretrig=%d rectime=%ds",
|
|
||||||
total_samples, pretrig_samples, rectime_seconds,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# STRT bytes +14..17 are unknown fields — confirmed NOT total/pretrig_samples
|
||||||
|
# (2026-04-14). total_samples and pretrig_samples are derived from the
|
||||||
|
# compliance config, which is the correct permanent source.
|
||||||
|
_strt_invalid = True
|
||||||
|
_sample_rate_default = 1024
|
||||||
|
total_samples = 0
|
||||||
|
pretrig_samples = 0
|
||||||
|
rectime_seconds = 0
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"_decode_a5_waveform: STRT flags=0x%04X next_key=%s prev_key=%s "
|
||||||
|
"mode=0x%02X → using compliance-config for total/pretrig",
|
||||||
|
struct.unpack_from(">H", strt, 4)[0],
|
||||||
|
strt[6:10].hex(),
|
||||||
|
strt[10:14].hex(),
|
||||||
|
strt[18],
|
||||||
|
)
|
||||||
|
|
||||||
|
event.total_samples = 0 # will be overwritten by compliance-config fallback below
|
||||||
|
event.pretrig_samples = 0
|
||||||
|
event.rectime_seconds = 0
|
||||||
|
|
||||||
# ── Collect per-frame waveform bytes with global offset tracking ─────────
|
# ── Collect per-frame waveform bytes with global offset tracking ─────────
|
||||||
# global_offset is the cumulative byte count across all frames, used to
|
# global_offset is the cumulative byte count across all frames, used to
|
||||||
# compute the channel alignment at each frame boundary.
|
# compute the channel alignment at each frame boundary.
|
||||||
@@ -1408,17 +1440,30 @@ def _decode_a5_waveform(
|
|||||||
for fi, db in enumerate(frames_data):
|
for fi, db in enumerate(frames_data):
|
||||||
w = db[7:]
|
w = db[7:]
|
||||||
|
|
||||||
# A5[0]: waveform begins after the 21-byte STRT record and 6-byte preamble.
|
# A5[0]: waveform begins immediately after the 21-byte STRT record.
|
||||||
# Layout: STRT(21B) + null-pad(2B) + 0xFF sentinel(4B) = 27 bytes total.
|
# Confirmed 2026-04-14: there is NO preamble after STRT — bytes 21+
|
||||||
|
# are raw ADC sample data. The earlier sp+27 skip was eating 6 bytes
|
||||||
|
# of real waveform, misaligning the channel decode for all subsequent
|
||||||
|
# frames.
|
||||||
if fi == 0:
|
if fi == 0:
|
||||||
sp = w.find(b"STRT")
|
sp = w.find(b"STRT")
|
||||||
if sp < 0:
|
if sp < 0:
|
||||||
continue
|
continue
|
||||||
wave = w[sp + 27 :]
|
wave = w[sp + 21 :]
|
||||||
|
log.info(
|
||||||
|
"_decode_a5_waveform: A5[0] waveform starts at sp+21; "
|
||||||
|
"first 24 wave bytes: %s",
|
||||||
|
wave[:24].hex(' '),
|
||||||
|
)
|
||||||
|
|
||||||
# Frame 7 carries event-time metadata strings ("Project:", "Client:", …)
|
# Metadata frame: contains "Project:", "Client:", etc. strings.
|
||||||
# and no waveform ADC data.
|
# Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture),
|
||||||
elif fi == 7:
|
# but confirmed variable position — it appears at whatever chunk index the
|
||||||
|
# device places it (observed at fi=6 for desk-thump events 2026-04-14).
|
||||||
|
# Skip ANY frame whose raw bytes contain b"Project:" — this is the same
|
||||||
|
# anchor used by stop_after_metadata in read_bulk_waveform_stream.
|
||||||
|
elif b"Project:" in w:
|
||||||
|
log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Terminator frames have page_key=0x0000 and are excluded upstream
|
# Terminator frames have page_key=0x0000 and are excluded upstream
|
||||||
@@ -1490,11 +1535,29 @@ def _decode_a5_waveform(
|
|||||||
|
|
||||||
running_offset += len(wave)
|
running_offset += len(wave)
|
||||||
|
|
||||||
|
n_decoded_total = len(tran)
|
||||||
log.debug(
|
log.debug(
|
||||||
"_decode_a5_waveform: decoded %d alignment-corrected sample-sets "
|
"_decode_a5_waveform: decoded %d alignment-corrected sample-sets "
|
||||||
"(skipped %d due to frame boundary misalignment)",
|
"(skipped %d due to frame boundary misalignment)",
|
||||||
len(tran), n_sets - len(tran),
|
n_decoded_total, n_sets - n_decoded_total,
|
||||||
)
|
)
|
||||||
|
# Log first 16 and last 8 samples for every channel — essential for
|
||||||
|
# validating decoder output and diagnosing flatline / misalignment issues.
|
||||||
|
_N = min(16, n_decoded_total)
|
||||||
|
_L = max(0, n_decoded_total - 8)
|
||||||
|
log.info(
|
||||||
|
"_decode_a5_waveform: first %d samples — "
|
||||||
|
"Tran=%s Vert=%s Long=%s Mic=%s",
|
||||||
|
_N,
|
||||||
|
tran[:_N], vert[:_N], long_[:_N], mic[:_N],
|
||||||
|
)
|
||||||
|
if n_decoded_total > 16:
|
||||||
|
log.info(
|
||||||
|
"_decode_a5_waveform: last 8 samples (idx %d–%d) — "
|
||||||
|
"Tran=%s Vert=%s Long=%s Mic=%s",
|
||||||
|
_L, n_decoded_total - 1,
|
||||||
|
tran[_L:], vert[_L:], long_[_L:], mic[_L:],
|
||||||
|
)
|
||||||
|
|
||||||
event.raw_samples = {
|
event.raw_samples = {
|
||||||
"Tran": tran,
|
"Tran": tran,
|
||||||
@@ -1503,6 +1566,64 @@ def _decode_a5_waveform(
|
|||||||
"Mic": mic,
|
"Mic": mic,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Derive pretrig/total from compliance config (always — STRT doesn't store them) ──
|
||||||
|
# total_samples and pretrig_samples are not stored in the STRT record (confirmed
|
||||||
|
# 2026-04-14). They are derived from compliance config record_time × sample_rate.
|
||||||
|
# _strt_invalid is always True; this block always runs.
|
||||||
|
#
|
||||||
|
# Formula:
|
||||||
|
# post_trig = record_time (s) × sample_rate (sps)
|
||||||
|
# pretrig = decoded_samples − post_trig
|
||||||
|
#
|
||||||
|
# This gives the pre-trigger window length, which correctly places t=0 in the
|
||||||
|
# waveform. If compliance_config is not available, leave pretrig=0 (viewer shows
|
||||||
|
# full waveform starting at t=0 — better than a crash or garbage).
|
||||||
|
n_decoded = len(tran)
|
||||||
|
if _strt_invalid:
|
||||||
|
if compliance_config is not None:
|
||||||
|
cc_sr = compliance_config.sample_rate or 1024
|
||||||
|
cc_rt = compliance_config.record_time
|
||||||
|
# Pre-trigger time is a separate device setting from Record Time.
|
||||||
|
# Blastware documentation confirms the compliance monitoring standard
|
||||||
|
# is 0.25 seconds pre-trigger ("the default Time Scale is -0.25 to 1
|
||||||
|
# second — this negative number accounts for the pre-trigger set for
|
||||||
|
# compliance monitoring").
|
||||||
|
# The pre-trigger field has not yet been located in the raw compliance
|
||||||
|
# config bytes; 0.25 s is used as the best-known default until it is
|
||||||
|
# decoded. TODO: locate pretrig_time in ComplianceConfig bytes.
|
||||||
|
_PRETRIG_SECONDS_DEFAULT = 0.25
|
||||||
|
derived_pretrig = int(round(_PRETRIG_SECONDS_DEFAULT * cc_sr))
|
||||||
|
if cc_rt and cc_rt > 0:
|
||||||
|
post_trig_samples = int(round(cc_rt * cc_sr))
|
||||||
|
# Clip total to pretrig + post_trig so the viewer doesn't show the
|
||||||
|
# zero-padded tail frames the device appends beyond the record window.
|
||||||
|
event.total_samples = derived_pretrig + post_trig_samples
|
||||||
|
event.pretrig_samples = derived_pretrig
|
||||||
|
event.rectime_seconds = int(round(cc_rt))
|
||||||
|
log.info(
|
||||||
|
"_decode_a5_waveform: pretrig=%d (%.2fs compliance default) "
|
||||||
|
"post_trig=%d total=%d record_time=%.1fs "
|
||||||
|
"sr=%d sps decoded=%d samples",
|
||||||
|
derived_pretrig, _PRETRIG_SECONDS_DEFAULT,
|
||||||
|
post_trig_samples, event.total_samples,
|
||||||
|
cc_rt, cc_sr, n_decoded,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
event.total_samples = n_decoded
|
||||||
|
event.pretrig_samples = derived_pretrig
|
||||||
|
log.warning(
|
||||||
|
"_decode_a5_waveform: STRT invalid, compliance config missing "
|
||||||
|
"record_time — pretrig=%d (%.2fs default), total=decoded %d",
|
||||||
|
derived_pretrig, _PRETRIG_SECONDS_DEFAULT, n_decoded,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
event.total_samples = n_decoded
|
||||||
|
log.warning(
|
||||||
|
"_decode_a5_waveform: STRT invalid, no compliance config available "
|
||||||
|
"— pretrig left as 0, total set to decoded count %d",
|
||||||
|
n_decoded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_record_type(data: bytes) -> Optional[str]:
|
def _extract_record_type(data: bytes) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+524
-486
File diff suppressed because it is too large
Load Diff
+50
-2
@@ -662,7 +662,11 @@ def device_event_waveform(
|
|||||||
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
|
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
|
||||||
info = client.connect()
|
info = client.connect()
|
||||||
# stop_after_index avoids downloading events beyond the one requested.
|
# stop_after_index avoids downloading events beyond the one requested.
|
||||||
events = client.get_events(full_waveform=True, stop_after_index=index)
|
events = client.get_events(
|
||||||
|
full_waveform=True,
|
||||||
|
stop_after_index=index,
|
||||||
|
compliance_config=info.compliance_config if info else None,
|
||||||
|
)
|
||||||
matching = [ev for ev in events if ev.index == index]
|
matching = [ev for ev in events if ev.index == index]
|
||||||
return matching[0] if matching else None, info
|
return matching[0] if matching else None, info
|
||||||
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||||
@@ -689,13 +693,22 @@ def device_event_waveform(
|
|||||||
if sample_rate is None and info.compliance_config:
|
if sample_rate is None and info.compliance_config:
|
||||||
sample_rate = info.compliance_config.sample_rate
|
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 = {
|
result = {
|
||||||
"index": ev.index,
|
"index": ev.index,
|
||||||
"record_type": ev.record_type,
|
"record_type": ev.record_type,
|
||||||
"timestamp": _serialise_timestamp(ev.timestamp),
|
"timestamp": _serialise_timestamp(ev.timestamp),
|
||||||
"total_samples": ev.total_samples,
|
"total_samples": ev.total_samples,
|
||||||
"pretrig_samples": ev.pretrig_samples,
|
"pretrig_samples": ev.pretrig_samples,
|
||||||
"rectime_seconds": ev.rectime_seconds,
|
"rectime_seconds": rectime_seconds,
|
||||||
"samples_decoded": samples_decoded,
|
"samples_decoded": samples_decoded,
|
||||||
"sample_rate": sample_rate,
|
"sample_rate": sample_rate,
|
||||||
"peak_values": _serialise_peak_values(ev.peak_values),
|
"peak_values": _serialise_peak_values(ev.peak_values),
|
||||||
@@ -1006,6 +1019,41 @@ def db_set_false_trigger(
|
|||||||
return {"status": "ok", "event_id": event_id, "false_trigger": value}
|
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")
|
@app.get("/db/monitor_log")
|
||||||
def db_monitor_log(
|
def db_monitor_log(
|
||||||
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
||||||
|
|||||||
+242
-27
@@ -548,6 +548,18 @@
|
|||||||
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
|
.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); }
|
.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 {
|
.db-empty {
|
||||||
color: var(--text-mute);
|
color: var(--text-mute);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -770,6 +782,12 @@
|
|||||||
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
<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="prev-btn" onclick="stepEvent(-1)" disabled>◀</button>
|
||||||
<button class="btn btn-ghost" id="next-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 class="event-chips" id="event-chips"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -781,6 +799,14 @@
|
|||||||
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs">—</div></div>
|
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs">—</div></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="waveform-area" style="flex:1; overflow-y:auto;">
|
||||||
<div id="empty-state">
|
<div id="empty-state">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -921,6 +947,7 @@
|
|||||||
<table class="db-table" id="hist-table">
|
<table class="db-table" id="hist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Serial</th>
|
<th>Serial</th>
|
||||||
<th>Tran (in/s)</th>
|
<th>Tran (in/s)</th>
|
||||||
@@ -1038,6 +1065,7 @@ let eventList = [];
|
|||||||
let currentEvent = 0;
|
let currentEvent = 0;
|
||||||
let charts = {};
|
let charts = {};
|
||||||
let geoRange = 6.206;
|
let geoRange = 6.206;
|
||||||
|
let lastWaveformData = null; // last successfully rendered waveform payload
|
||||||
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
|
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
|
||||||
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
|
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
|
||||||
|
|
||||||
@@ -1499,13 +1527,14 @@ function updatePeaksBar(ev) {
|
|||||||
|
|
||||||
async function loadWaveform() {
|
async function loadWaveform() {
|
||||||
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
|
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
|
||||||
const idx = currentEvent;
|
const idx = currentEvent;
|
||||||
|
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
|
||||||
document.getElementById('load-btn').disabled = true;
|
document.getElementById('load-btn').disabled = true;
|
||||||
setStatus('Fetching waveform…', 'loading');
|
setStatus('Fetching waveform…', 'loading');
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`);
|
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
|
||||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
data = await r.json();
|
data = await r.json();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -1514,46 +1543,41 @@ async function loadWaveform() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastWaveformData = data;
|
||||||
renderWaveform(data);
|
renderWaveform(data);
|
||||||
document.getElementById('load-btn').disabled = false;
|
document.getElementById('load-btn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWaveform(data) {
|
// ── Shared waveform chart builder ──────────────────────────────────────────────
|
||||||
|
// Renders waveform channel charts into chartsEl, destroys+replaces instances in
|
||||||
|
// chartsStore. emptyEl (optional) is shown/hidden based on decoded sample count.
|
||||||
|
function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
|
||||||
const sr = data.sample_rate || 1024;
|
const sr = data.sample_rate || 1024;
|
||||||
const pretrig = data.pretrig_samples || 0;
|
const pretrig = data.pretrig_samples || 0;
|
||||||
const decoded = data.samples_decoded || 0;
|
const decoded = data.samples_decoded || 0;
|
||||||
const total = data.total_samples || decoded;
|
|
||||||
const channels = data.channels || {};
|
const channels = data.channels || {};
|
||||||
|
|
||||||
// Status bar
|
// Destroy old chart instances
|
||||||
const bar = document.getElementById('status-bar');
|
Object.values(chartsStore).forEach(c => c.destroy());
|
||||||
bar.innerHTML = '';
|
for (const k in chartsStore) delete chartsStore[k];
|
||||||
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 (decoded === 0) {
|
||||||
document.getElementById('empty-state').style.display = 'flex';
|
if (emptyEl) {
|
||||||
document.getElementById('empty-state').querySelector('p').textContent =
|
emptyEl.style.display = 'flex';
|
||||||
data.record_type === 'Waveform'
|
const p = emptyEl.querySelector('p');
|
||||||
|
if (p) p.textContent = data.record_type === 'Waveform'
|
||||||
? 'No samples decoded — check server logs'
|
? 'No samples decoded — check server logs'
|
||||||
: `Record type "${data.record_type}" — waveform not supported yet`;
|
: `Record type "${data.record_type}" — waveform not supported yet`;
|
||||||
document.getElementById('charts').style.display = 'none';
|
}
|
||||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
chartsEl.style.display = 'none';
|
||||||
|
chartsEl.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
||||||
document.getElementById('empty-state').style.display = 'none';
|
if (emptyEl) emptyEl.style.display = 'none';
|
||||||
const chartsDiv = document.getElementById('charts');
|
chartsEl.style.display = 'flex';
|
||||||
chartsDiv.style.display = 'flex';
|
chartsEl.innerHTML = '';
|
||||||
chartsDiv.innerHTML = '';
|
|
||||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
|
||||||
|
|
||||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||||
|
|
||||||
@@ -1605,9 +1629,9 @@ function renderWaveform(data) {
|
|||||||
const cw = document.createElement('div');
|
const cw = document.createElement('div');
|
||||||
cw.className = 'chart-canvas-wrap';
|
cw.className = 'chart-canvas-wrap';
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap);
|
cw.appendChild(canvas); wrap.appendChild(cw); chartsEl.appendChild(wrap);
|
||||||
|
|
||||||
charts[ch] = new Chart(canvas, {
|
chartsStore[ch] = new Chart(canvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
|
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
|
||||||
options: {
|
options: {
|
||||||
@@ -1642,6 +1666,64 @@ function renderWaveform(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWaveform(data) {
|
||||||
|
const sr = data.sample_rate || 1024;
|
||||||
|
const pretrig = data.pretrig_samples || 0;
|
||||||
|
const decoded = data.samples_decoded || 0;
|
||||||
|
const total = data.total_samples || decoded;
|
||||||
|
lastWaveformData = data;
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
const bar = document.getElementById('status-bar');
|
||||||
|
bar.innerHTML = '';
|
||||||
|
bar.className = 'ok';
|
||||||
|
const ts = data.timestamp;
|
||||||
|
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
|
||||||
|
addPill(`${data.record_type || '?'}`);
|
||||||
|
addPill(`${sr} sps`);
|
||||||
|
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
|
||||||
|
addPill(`pretrig ${pretrig}`);
|
||||||
|
addPill(`${data.rectime_seconds ?? '?'} s`);
|
||||||
|
|
||||||
|
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
|
||||||
|
updateDebugPanel(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug panel population ─────────────────────────────────────────────────────
|
||||||
|
function _fillDebugPanel(data, dbg, cont) {
|
||||||
|
if (!dbg || !cont) return;
|
||||||
|
const channels = data.channels || {};
|
||||||
|
const pv = data.peak_values || {};
|
||||||
|
const scale = geoRange / 32767;
|
||||||
|
const geoChans = ['Tran', 'Vert', 'Long'];
|
||||||
|
|
||||||
|
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
|
||||||
|
for (const ch of [...geoChans, 'Mic']) {
|
||||||
|
const raw = (channels[ch] || []).slice(0, 8);
|
||||||
|
if (raw.length === 0) continue;
|
||||||
|
const maxAbs = Math.max(...raw.map(Math.abs));
|
||||||
|
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
|
||||||
|
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
|
||||||
|
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
|
||||||
|
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
|
||||||
|
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
|
||||||
|
if (nullPeaks.length > 0) {
|
||||||
|
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
|
||||||
|
}
|
||||||
|
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
|
||||||
|
|
||||||
|
cont.innerHTML = html;
|
||||||
|
dbg.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDebugPanel(data) {
|
||||||
|
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
|
||||||
|
}
|
||||||
|
|
||||||
// ── DB tabs ────────────────────────────────────────────────────────────────────
|
// ── DB tabs ────────────────────────────────────────────────────────────────────
|
||||||
let histLoaded = false;
|
let histLoaded = false;
|
||||||
let unitsLoaded = false;
|
let unitsLoaded = false;
|
||||||
@@ -1745,6 +1827,7 @@ async function loadHistory() {
|
|||||||
const pvs = ev.peak_vector_sum;
|
const pvs = ev.peak_vector_sum;
|
||||||
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
|
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform">〜</button></td>
|
||||||
<td>${_fmtTs(ev.timestamp)}</td>
|
<td>${_fmtTs(ev.timestamp)}</td>
|
||||||
<td class="td-key">${ev.serial ?? '—'}</td>
|
<td class="td-key">${ev.serial ?? '—'}</td>
|
||||||
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
|
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
|
||||||
@@ -1919,9 +2002,86 @@ async function loadSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DB waveform modal ─────────────────────────────────────────────────────────
|
||||||
|
let modalCharts = {};
|
||||||
|
|
||||||
|
async function openDbWaveformModal(id) {
|
||||||
|
const modal = document.getElementById('wf-modal');
|
||||||
|
const titleEl = document.getElementById('wf-modal-title');
|
||||||
|
const chartsEl = document.getElementById('wf-modal-charts');
|
||||||
|
const emptyEl = document.getElementById('wf-modal-empty');
|
||||||
|
const peaksEl = document.getElementById('wf-modal-peaks');
|
||||||
|
const debugEl = document.getElementById('wf-modal-debug');
|
||||||
|
|
||||||
|
// Show modal in loading state
|
||||||
|
titleEl.textContent = 'Loading…';
|
||||||
|
peaksEl.classList.remove('visible');
|
||||||
|
if (debugEl) debugEl.style.display = 'none';
|
||||||
|
chartsEl.style.display = 'none';
|
||||||
|
chartsEl.innerHTML = '';
|
||||||
|
emptyEl.style.display = 'flex';
|
||||||
|
emptyEl.querySelector('p').textContent = 'Loading waveform…';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
|
||||||
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||||
|
data = await r.json();
|
||||||
|
} catch(e) {
|
||||||
|
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
|
||||||
|
if (data.peak_values) {
|
||||||
|
const pv = data.peak_values;
|
||||||
|
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
|
||||||
|
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
|
||||||
|
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
|
||||||
|
const sr = data.sample_rate || 1024;
|
||||||
|
const decoded = data.samples_decoded || 0;
|
||||||
|
const total = data.total_samples || decoded;
|
||||||
|
const pretrig = data.pretrig_samples || 0;
|
||||||
|
let tsStr = '';
|
||||||
|
if (data.timestamp) {
|
||||||
|
const tsDisplay = typeof data.timestamp === 'object'
|
||||||
|
? (data.timestamp.display || String(data.timestamp))
|
||||||
|
: new Date(data.timestamp).toLocaleString();
|
||||||
|
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
|
||||||
|
}
|
||||||
|
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
|
||||||
|
|
||||||
|
// Peaks bar
|
||||||
|
const pv = data.peak_values || {};
|
||||||
|
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
|
||||||
|
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
|
||||||
|
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
|
||||||
|
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
|
||||||
|
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
|
||||||
|
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
|
||||||
|
peaksEl.classList.add('visible');
|
||||||
|
|
||||||
|
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
|
||||||
|
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWfModal() {
|
||||||
|
const modal = document.getElementById('wf-modal');
|
||||||
|
if (!modal || modal.style.display === 'none') return;
|
||||||
|
modal.style.display = 'none';
|
||||||
|
// Destroy chart instances to free canvas memory
|
||||||
|
Object.values(modalCharts).forEach(c => c.destroy());
|
||||||
|
for (const k in modalCharts) delete modalCharts[k];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
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 === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
|
||||||
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
|
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
|
||||||
});
|
});
|
||||||
@@ -1935,5 +2095,60 @@ document.getElementById('api-base').value = window.location.origin;
|
|||||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
|
||||||
|
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
|
||||||
|
<div id="wf-modal"
|
||||||
|
style="display:none; position:fixed; inset:0; z-index:1000;
|
||||||
|
background:rgba(1,4,9,0.88); align-items:flex-start;
|
||||||
|
justify-content:center; padding:24px; overflow:auto;"
|
||||||
|
onclick="if(event.target===this)closeWfModal()">
|
||||||
|
<div style="background:var(--surface); border:1px solid var(--border);
|
||||||
|
border-radius:8px; width:100%; max-width:1100px;
|
||||||
|
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
|
||||||
|
|
||||||
|
<!-- Header row -->
|
||||||
|
<div style="display:flex; align-items:center; padding:10px 16px;
|
||||||
|
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
|
||||||
|
<div id="wf-modal-title"
|
||||||
|
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
<button onclick="closeWfModal()"
|
||||||
|
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
|
||||||
|
font-size:20px; line-height:1; padding:0 2px; flex-shrink:0;"
|
||||||
|
title="Close (Esc)">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peaks bar — reuses .peaks-bar styles from live Events tab -->
|
||||||
|
<div class="peaks-bar" id="wf-modal-peaks">
|
||||||
|
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="wf-mpk-tran">—</div></div>
|
||||||
|
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="wf-mpk-vert">—</div></div>
|
||||||
|
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="wf-mpk-long">—</div></div>
|
||||||
|
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="wf-mpk-mic">—</div></div>
|
||||||
|
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="wf-mpk-pvs">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug panel (same as live debug panel, hidden by default) -->
|
||||||
|
<div id="wf-modal-debug"
|
||||||
|
style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
|
||||||
|
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
|
||||||
|
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
|
||||||
|
onclick="document.getElementById('wf-modal-debug').style.display='none'">hide</span>
|
||||||
|
<div id="wf-modal-debug-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waveform area -->
|
||||||
|
<div style="flex:1; overflow-y:auto; min-height:200px;">
|
||||||
|
<div id="wf-modal-empty"
|
||||||
|
style="display:flex; flex-direction:column; align-items:center;
|
||||||
|
justify-content:center; padding:60px 20px; color:var(--text-dim); gap:12px;">
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
<div id="wf-modal-charts" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+254
-90
@@ -175,6 +175,27 @@
|
|||||||
}
|
}
|
||||||
#connect-btn:hover { background: #2ea043; }
|
#connect-btn:hover { background: #2ea043; }
|
||||||
#connect-btn:disabled { background: #21262d; color: #484f58; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -193,6 +214,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="connect-btn" onclick="connectUnit()">Connect</button>
|
<button id="connect-btn" onclick="connectUnit()">Connect</button>
|
||||||
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</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="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
||||||
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -219,6 +246,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status-bar">Ready — enter device host and click Connect.</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">
|
<div id="empty-state">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -244,6 +275,46 @@
|
|||||||
let eventList = []; // populated from /device/events after connect
|
let eventList = []; // populated from /device/events after connect
|
||||||
let currentEventIndex = 0;
|
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 = '') {
|
function setStatus(msg, cls = '') {
|
||||||
const bar = document.getElementById('status-bar');
|
const bar = document.getElementById('status-bar');
|
||||||
bar.textContent = msg;
|
bar.textContent = msg;
|
||||||
@@ -364,7 +435,8 @@
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
setStatus('Fetching waveform…', 'loading');
|
setStatus('Fetching waveform…', 'loading');
|
||||||
|
|
||||||
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
|
||||||
|
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -404,8 +476,11 @@
|
|||||||
bar.innerHTML = '';
|
bar.innerHTML = '';
|
||||||
bar.className = 'ok';
|
bar.className = 'ok';
|
||||||
const ts = data.timestamp;
|
const ts = data.timestamp;
|
||||||
if (ts) {
|
const tsDisplay = ts
|
||||||
bar.textContent = `Event #${data.index} — ${ts.display} `;
|
? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
|
||||||
|
: null;
|
||||||
|
if (tsDisplay) {
|
||||||
|
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
|
||||||
} else {
|
} else {
|
||||||
bar.textContent = `Event #${data.index} `;
|
bar.textContent = `Event #${data.index} `;
|
||||||
}
|
}
|
||||||
@@ -413,7 +488,14 @@
|
|||||||
appendMeta('sr', `${sr} sps`);
|
appendMeta('sr', `${sr} sps`);
|
||||||
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
||||||
appendMeta('pretrig', pretrig);
|
appendMeta('pretrig', pretrig);
|
||||||
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
|
// rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
|
||||||
|
// _decode_a5_waveform. Also show the compliance config record_time for reference.
|
||||||
|
const cfgRt = unitInfo?.compliance_config?.record_time;
|
||||||
|
const strtRt = data.rectime_seconds;
|
||||||
|
const rtStr = (strtRt !== null && strtRt !== undefined)
|
||||||
|
? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
|
||||||
|
: (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
|
||||||
|
appendMeta('rectime', rtStr);
|
||||||
|
|
||||||
// No waveform data — show a clear reason instead of empty charts
|
// No waveform data — show a clear reason instead of empty charts
|
||||||
if (decoded === 0) {
|
if (decoded === 0) {
|
||||||
@@ -423,14 +505,20 @@
|
|||||||
? 'Waveform decode returned no samples — check server logs'
|
? 'Waveform decode returned no samples — check server logs'
|
||||||
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
|
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
|
||||||
document.getElementById('charts').style.display = 'none';
|
document.getElementById('charts').style.display = 'none';
|
||||||
|
document.getElementById('debug-panel').classList.remove('visible');
|
||||||
Object.values(charts).forEach(c => c.destroy());
|
Object.values(charts).forEach(c => c.destroy());
|
||||||
charts = {};
|
charts = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build time axis (ms)
|
// Clip to total_samples to exclude zero-padding the device appends beyond
|
||||||
const times = Array.from({ length: decoded }, (_, i) =>
|
// the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
|
||||||
((i - pretrig) / sr * 1000).toFixed(2)
|
// decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
|
||||||
|
const displayCount = (total > 0 && total < decoded) ? total : decoded;
|
||||||
|
|
||||||
|
// Build time axis in seconds (matching Blastware event report layout).
|
||||||
|
const times = Array.from({ length: displayCount }, (_, i) =>
|
||||||
|
((i - pretrig) / sr).toFixed(3)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show charts area
|
// Show charts area
|
||||||
@@ -447,6 +535,15 @@
|
|||||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||||
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
|
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)) {
|
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||||
const samples = channels[ch];
|
const samples = channels[ch];
|
||||||
if (!samples || samples.length === 0) continue;
|
if (!samples || samples.length === 0) continue;
|
||||||
@@ -455,22 +552,38 @@
|
|||||||
const isGeo = ch !== 'Mic';
|
const isGeo = ch !== 'Mic';
|
||||||
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
|
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) {
|
if (isGeo) {
|
||||||
// Geo channels: counts × (range / 32767) → in/s
|
// Geo channels: counts × (range / 32767) → in/s
|
||||||
|
// Scale factor for the waveform shape (may need calibration per unit)
|
||||||
const scale = geoRange / 32767;
|
const scale = geoRange / 32767;
|
||||||
plotSamples = samples.map(c => c * scale);
|
plotSamples = clippedSamples.map(c => c * scale);
|
||||||
const peakIns = Math.max(...plotSamples.map(Math.abs));
|
|
||||||
|
// Use the device-computed 0C record peak for the label (authoritative).
|
||||||
|
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
|
||||||
|
peak0C = peakValues0C[ch];
|
||||||
|
const peakIns = (peak0C !== null && peak0C !== undefined)
|
||||||
|
? peak0C
|
||||||
|
: Math.max(...plotSamples.map(Math.abs));
|
||||||
peakLabel = `${peakIns.toFixed(5)} in/s`;
|
peakLabel = `${peakIns.toFixed(5)} in/s`;
|
||||||
yUnit = 'in/s';
|
yUnit = 'in/s';
|
||||||
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
||||||
tickFmt = v => v.toFixed(4);
|
tickFmt = v => v.toFixed(4);
|
||||||
} else {
|
} else {
|
||||||
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
|
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
|
||||||
const peakCounts = Math.max(...samples.map(Math.abs));
|
const peakCounts = Math.max(...clippedSamples.map(Math.abs));
|
||||||
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
const micScale = (micPeakPsi !== null && peakCounts > 0)
|
||||||
? Math.abs(micPeakPsi) / peakCounts
|
? Math.abs(micPeakPsi) / peakCounts
|
||||||
: 1.0;
|
: 1.0;
|
||||||
plotSamples = samples.map(c => c * micScale);
|
plotSamples = clippedSamples.map(c => c * micScale);
|
||||||
const peakPsi = Math.max(...plotSamples.map(Math.abs));
|
const peakPsi = Math.max(...plotSamples.map(Math.abs));
|
||||||
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
|
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
|
||||||
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
|
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
|
||||||
@@ -504,88 +617,139 @@
|
|||||||
renderData = plotSamples.filter((_, i) => i % step === 0);
|
renderData = plotSamples.filter((_, i) => i % step === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chart = new Chart(canvas, {
|
let chart;
|
||||||
type: 'line',
|
try {
|
||||||
data: {
|
chart = new Chart(canvas, {
|
||||||
labels: renderTimes,
|
type: 'line',
|
||||||
datasets: [{
|
data: {
|
||||||
data: renderData,
|
labels: renderTimes,
|
||||||
borderColor: color,
|
datasets: [{
|
||||||
borderWidth: 1,
|
data: renderData,
|
||||||
pointRadius: 0,
|
borderColor: color,
|
||||||
tension: 0,
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: items => `t = ${items[0].label} s`,
|
||||||
|
label: item => tooltipFmt(item.raw),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'category',
|
||||||
|
ticks: {
|
||||||
|
color: '#484f58',
|
||||||
|
maxTicksLimit: 10,
|
||||||
|
maxRotation: 0,
|
||||||
|
callback: (val, i) => renderTimes[i] + ' s',
|
||||||
|
},
|
||||||
|
grid: { color: '#21262d' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
|
||||||
|
// decode artifacts (which inflate autoscale to full range) don't
|
||||||
|
// squash the actual blast signal into an invisible flat line.
|
||||||
|
// The 0C peak value is authoritative for the true signal amplitude.
|
||||||
|
// Guard: only apply if peak0C is a valid finite positive number.
|
||||||
|
...(isGeo && peak0C !== null && peak0C !== undefined
|
||||||
|
&& isFinite(peak0C) && peak0C > 0 ? {
|
||||||
|
min: -(peak0C * 1.4),
|
||||||
|
max: (peak0C * 1.4),
|
||||||
|
} : {}),
|
||||||
|
ticks: {
|
||||||
|
color: '#484f58',
|
||||||
|
maxTicksLimit: 5,
|
||||||
|
callback: v => tickFmt(v),
|
||||||
|
},
|
||||||
|
grid: { color: '#21262d' },
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: yUnit,
|
||||||
|
color: '#484f58',
|
||||||
|
font: { size: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [{
|
||||||
|
// Draw trigger line at t=0
|
||||||
|
id: 'triggerLine',
|
||||||
|
afterDraw(chart) {
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
const xAxis = chart.scales.x;
|
||||||
|
const yAxis = chart.scales.y;
|
||||||
|
|
||||||
|
// Find index of the trigger point (t ≥ 0.000 s)
|
||||||
|
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
|
||||||
|
if (zeroIdx < 0) return;
|
||||||
|
|
||||||
|
const x = xAxis.getPixelForValue(zeroIdx);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, yAxis.top);
|
||||||
|
ctx.lineTo(x, yAxis.bottom);
|
||||||
|
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([4, 3]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
},
|
||||||
}],
|
}],
|
||||||
},
|
});
|
||||||
options: {
|
} catch (err) {
|
||||||
animation: false,
|
console.error(`Chart.js error for channel ${ch}:`, err);
|
||||||
responsive: true,
|
canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
|
||||||
maintainAspectRatio: false,
|
}
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
callbacks: {
|
|
||||||
title: items => `t = ${items[0].label} ms`,
|
|
||||||
label: item => tooltipFmt(item.raw),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'category',
|
|
||||||
ticks: {
|
|
||||||
color: '#484f58',
|
|
||||||
maxTicksLimit: 10,
|
|
||||||
maxRotation: 0,
|
|
||||||
callback: (val, i) => renderTimes[i] + ' ms',
|
|
||||||
},
|
|
||||||
grid: { color: '#21262d' },
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
ticks: {
|
|
||||||
color: '#484f58',
|
|
||||||
maxTicksLimit: 5,
|
|
||||||
callback: v => tickFmt(v),
|
|
||||||
},
|
|
||||||
grid: { color: '#21262d' },
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: yUnit,
|
|
||||||
color: '#484f58',
|
|
||||||
font: { size: 10 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [{
|
|
||||||
// Draw trigger line at t=0
|
|
||||||
id: 'triggerLine',
|
|
||||||
afterDraw(chart) {
|
|
||||||
const ctx = chart.ctx;
|
|
||||||
const xAxis = chart.scales.x;
|
|
||||||
const yAxis = chart.scales.y;
|
|
||||||
|
|
||||||
// Find index of t=0
|
if (chart) charts[ch] = chart;
|
||||||
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
|
// Auto-detect API base from wherever this page was served from
|
||||||
|
|||||||
Reference in New Issue
Block a user