feat: add waveform download and storage.

This commit is contained in:
2026-04-14 02:15:33 -04:00
parent b384ba66d1
commit edb4698bfb
5 changed files with 195 additions and 7 deletions
+58 -1
View File
@@ -445,8 +445,19 @@ class AchSession:
# ── Persist to SQLite DB ───────────────────────────────────── # ── Persist to SQLite DB ─────────────────────────────────────
_session_start = datetime.datetime.now() _session_start = datetime.datetime.now()
try: try:
# Build waveform blobs for events that have full raw_samples
_waveform_blobs = {}
for _ev in new_events:
if _ev._waveform_key and _ev.raw_samples:
_blob = _build_waveform_blob(_ev)
if _blob:
_waveform_blobs[_ev._waveform_key.hex()] = _blob
if _waveform_blobs:
log.info(" [DB] waveform blobs prepared: %d", len(_waveform_blobs))
_ev_ins, _ev_skip = self.db.insert_events( _ev_ins, _ev_skip = self.db.insert_events(
new_events, serial=serial or self.peer, session_id=None new_events, serial=serial or self.peer, session_id=None,
waveform_blobs=_waveform_blobs,
) )
_ml_ins, _ml_skip = self.db.insert_monitor_log( _ml_ins, _ml_skip = self.db.insert_monitor_log(
new_monitor_entries, session_id=None new_monitor_entries, session_id=None
@@ -599,6 +610,52 @@ def _event_to_dict(e: Event) -> dict:
} }
def _build_waveform_blob(e: Event) -> Optional[str]:
"""
Serialise a downloaded event's full waveform data as a JSON string for
storage in the DB waveform_blob column.
Returns the same shape as GET /device/event/{index}/waveform so the
waveform viewer can consume either source without modification.
Returns None if the event has no raw_samples (e.g. metadata-only download).
"""
raw = e.raw_samples or {}
if not raw:
return None
pv = e.peak_values
peak_values = None
if pv:
peak_values = {
"tran": pv.tran,
"vert": pv.vert,
"long": pv.long,
"micl_psi": pv.micl,
"peak_vector_sum": pv.peak_vector_sum,
}
ts = e.timestamp
timestamp_str = (
f"{ts.year:04d}-{ts.month:02d}-{ts.day:02d}T"
f"{ts.hour:02d}:{ts.minute:02d}:{ts.second:02d}"
if ts else None
)
blob = {
"index": e.index,
"record_type": e.record_type,
"timestamp": timestamp_str,
"total_samples": e.total_samples,
"pretrig_samples": e.pretrig_samples,
"rectime_seconds": e.rectime_seconds,
"samples_decoded": len(raw.get("Tran", [])),
"sample_rate": e.sample_rate,
"peak_values": peak_values,
"channels": raw,
}
return json.dumps(blob)
def _monitor_log_entry_to_dict(e: MonitorLogEntry) -> dict: def _monitor_log_entry_to_dict(e: MonitorLogEntry) -> dict:
return { return {
"key": e.key, "key": e.key,
+42 -4
View File
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS events (
sample_rate INTEGER, sample_rate INTEGER,
record_type TEXT, -- "single_shot" | "continuous" record_type TEXT, -- "single_shot" | "continuous"
false_trigger INTEGER NOT NULL DEFAULT 0, -- 0=no, 1=yes (manual flag) false_trigger INTEGER NOT NULL DEFAULT 0, -- 0=no, 1=yes (manual flag)
waveform_blob TEXT, -- JSON waveform response (channels + metadata)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(serial, timestamp) UNIQUE(serial, timestamp)
); );
@@ -216,6 +217,17 @@ class SeismoDb:
""") """)
log.info("_migrate: monitor_log table rebuilt OK") log.info("_migrate: monitor_log table rebuilt OK")
# Migration 3: add waveform_blob column to events (nullable TEXT).
# ALter TABLE ADD COLUMN is safe in SQLite for nullable columns — no rebuild needed.
col_names = {
row[1]
for row in conn.execute("PRAGMA table_info(events)").fetchall()
}
if "waveform_blob" not in col_names:
log.info("_migrate: adding waveform_blob column to events")
conn.execute("ALTER TABLE events ADD COLUMN waveform_blob TEXT")
log.info("_migrate: waveform_blob column added OK")
@staticmethod @staticmethod
def _iso(dt: Optional[datetime.datetime]) -> Optional[str]: def _iso(dt: Optional[datetime.datetime]) -> Optional[str]:
return dt.isoformat() if dt is not None else None return dt.isoformat() if dt is not None else None
@@ -282,12 +294,19 @@ class SeismoDb:
*, *,
serial: str, serial: str,
session_id: Optional[str] = None, session_id: Optional[str] = None,
waveform_blobs: Optional[dict[str, str]] = None,
) -> tuple[int, int]: ) -> tuple[int, int]:
""" """
Insert triggered events. Silently skips duplicates (serial+timestamp). Insert triggered events. Silently skips duplicates (serial+timestamp).
Returns (inserted, skipped). Returns (inserted, skipped).
waveform_blobs: optional mapping of waveform_key (hex str) → JSON string
containing the full waveform response (channels + metadata). When provided,
the blob is stored alongside the event row and is retrievable via
GET /db/events/{id}/waveform.
""" """
inserted = skipped = 0 inserted = skipped = 0
blobs = waveform_blobs or {}
with self._connect() as conn: with self._connect() as conn:
for ev in events: for ev in events:
key = ev._waveform_key.hex() if ev._waveform_key else None key = ev._waveform_key.hex() if ev._waveform_key else None
@@ -305,8 +324,9 @@ class SeismoDb:
except Exception: except Exception:
ts = str(ev.timestamp) ts = str(ev.timestamp)
pv = ev.peak_values pv = ev.peak_values
pi = ev.project_info pi = ev.project_info
blob = blobs.get(key)
try: try:
conn.execute( conn.execute(
@@ -315,8 +335,8 @@ class SeismoDb:
(id, serial, waveform_key, session_id, timestamp, (id, serial, waveform_key, session_id, timestamp,
tran_ppv, vert_ppv, long_ppv, peak_vector_sum, mic_ppv, tran_ppv, vert_ppv, long_ppv, peak_vector_sum, mic_ppv,
project, client, operator, sensor_location, project, client, operator, sensor_location,
sample_rate, record_type) sample_rate, record_type, waveform_blob)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
self._new_id(), serial, key, session_id, ts, self._new_id(), serial, key, session_id, ts,
@@ -331,6 +351,7 @@ class SeismoDb:
pi.sensor_location if pi else None, pi.sensor_location if pi else None,
ev.sample_rate, ev.sample_rate,
ev.record_type, ev.record_type,
blob,
), ),
) )
inserted += 1 inserted += 1
@@ -387,6 +408,23 @@ class SeismoDb:
) )
return cur.rowcount > 0 return cur.rowcount > 0
def get_event_waveform(self, event_id: str) -> tuple[bool, Optional[str]]:
"""
Return (found, waveform_blob) for a given event UUID.
found=False means the event row doesn't exist.
found=True, blob=None means the event exists but has no stored waveform
(e.g. downloaded before waveform storage was implemented).
found=True, blob=<str> means the full waveform JSON is available.
"""
with self._connect() as conn:
row = conn.execute(
"SELECT waveform_blob FROM events WHERE id = ?", (event_id,)
).fetchone()
if row is None:
return False, None
return True, row["waveform_blob"]
# ── Monitor log ─────────────────────────────────────────────────────────── # ── Monitor log ───────────────────────────────────────────────────────────
def insert_monitor_log( def insert_monitor_log(
+35
View File
@@ -1006,6 +1006,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, vert, long, 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"),
+15
View File
@@ -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;
@@ -921,6 +933,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>
@@ -1744,7 +1757,9 @@ 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>${_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>
+45 -2
View File
@@ -244,6 +244,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;
@@ -404,8 +444,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} `;
} }