feat: unify DB and live waveform views with inline modal overlay

- Extract _buildWaveformCharts() shared renderer used by both live Events
  tab and new DB history modal (no duplicate chart-building code)
- Replace window.open(waveform_viewer.html) with openDbWaveformModal()
  that renders an inline overlay with full peaks bar, debug panel, and
  4-channel charts — same rendering path as the live device view
- Fix timestamp display for DB blobs (ISO string vs {display:...} object)
- Normalize old blob peak_values keys (tran/vert/long → tran_in_s etc.)
  for backward compat with pre-fix ACH blobs
- Close modal via × button, Esc key, or backdrop click; destroy Chart.js
  instances on close to free canvas memory
- Fix onclick UUID quoting in History table (UUIDs need quoted string arg)
- Fix ach_server.py peak_values key names to match viewer expectations
- Extract _fillDebugPanel() so same debug content works in both contexts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 23:36:32 -04:00
parent 727bfed5c4
commit bbd574e7d5
5 changed files with 1858 additions and 1665 deletions
+1 -6
View File
@@ -25,9 +25,4 @@ Thumbs.db
# Analyzer outputs # Analyzer outputs
*.report *.report
claude_export_*.md claude_exp
# Frame database
*.db
*.db-wal
*.db-shm
+266 -268
View File
@@ -1,268 +1,266 @@
# 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) - [ ] Vibra
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
+838 -838
View File
File diff suppressed because it is too large Load Diff
+524 -524
View File
File diff suppressed because it is too large Load Diff
+229 -29
View File
@@ -782,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&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></div> <div class="event-chips" id="event-chips"></div>
</div> </div>
@@ -793,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">
@@ -1051,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' };
@@ -1512,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) {
@@ -1527,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;
@@ -1618,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: {
@@ -1655,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;
@@ -1757,9 +1826,8 @@ async function loadHistory() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
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);
const waveformUrl = `${api()}/waveform?db_id=${encodeURIComponent(ev.id)}&api_base=${encodeURIComponent(api())}`;
tr.innerHTML = ` tr.innerHTML = `
<td><button class="wf-btn" onclick="window.open('${waveformUrl}','_blank')" title="View waveform">〜</button></td> <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>
@@ -1934,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(); }
}); });
@@ -1950,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>