feat: add waveform download and storage.
This commit is contained in:
+58
-1
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} `;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user