11 Commits

Author SHA1 Message Date
claude 6a0f0ae2e4 chore: doc/gitignore cleanup 2026-04-14 23:53:47 -04:00
claude bbd574e7d5 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>
2026-04-14 23:36:32 -04:00
claude 727bfed5c4 fix: add debug panel for raw ADC counts and decode diagnostics 2026-04-14 21:02:40 -04:00
claude 8d0537389d fix: continue to debug and fix strt amd waveform weirdness 2026-04-14 19:44:37 -04:00
claude c5a7914032 fix: update STRT record parsing to reflect confirmed offsets and derive total/pretrig_samples from compliance config 2026-04-14 18:32:16 -04:00
claude dbb9febe2c fix: update STRT parsing to extract additional bytes for total_samples and pretrig_samples 2026-04-14 18:02:45 -04:00
claude 9ae968b108 fix: peak0c scope bug and strt cross check fix 2026-04-14 17:46:38 -04:00
claude 171dc2551c fix: add STRT invalid detction, ach server passes config for get events, 2026-04-14 17:08:27 -04:00
claude 4f4c1a8f64 debug: figuring out whats wrong with waveform viewer 2026-04-14 16:00:14 -04:00
claude 0da88ec6aa fix: redefines rectime_seconds from strt[18] byte to new computed time.
The server now re-computes rectime_seconds using the actual sample rate from the compliance config (overriding the default 1024 in the client), so if the device runs at 2048 or 4096 sps it's still correct.

Viewer — The rectime display now shows Xs (stored) / Ys (cfg) so you can compare the STRT-derived duration against the compliance config's record_time setting side-by-side. I also clamped the y-axis to ±(0C peak × 1.4) so near-saturation decode artifacts don't squash the real blast signal into a flat line.
2026-04-14 14:19:17 -04:00
claude edb4698bfb feat: add waveform download and storage. 2026-04-14 02:15:33 -04:00
8 changed files with 2408 additions and 1684 deletions
+77
View File
@@ -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,
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 2161 s vs actual 3.0 s (ratio 720×).
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)
After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
+62 -1
View File
@@ -371,6 +371,7 @@ class AchSession:
full_waveform=True,
stop_after_index=stop_idx,
skip_waveform_for_keys=seen_keys if seen_keys else None,
compliance_config=device_info.compliance_config if device_info else None,
)
# Filter to events whose keys we haven't saved before.
@@ -445,8 +446,19 @@ class AchSession:
# ── Persist to SQLite DB ─────────────────────────────────────
_session_start = datetime.datetime.now()
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(
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(
new_monitor_entries, session_id=None
@@ -599,6 +611,55 @@ 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:
# Key names must match sfm/server.py _serialise_peak_values() so the
# waveform viewer (which reads tran_in_s / vert_in_s / long_in_s) works
# identically in both live-device mode and DB mode.
peak_values = {
"tran_in_s": pv.tran,
"vert_in_s": pv.vert,
"long_in_s": 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:
return {
"key": e.key,
+155 -34
View File
@@ -448,7 +448,7 @@ class MiniMateClient:
proto.confirm_erase_all()
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
1E → 0A → 0C → 5A → 1F event-iterator protocol.
@@ -479,6 +479,7 @@ class MiniMateClient:
ProtocolError: on unrecoverable communication failure.
"""
proto = self._require_proto()
_compliance_config = compliance_config # passed through to _decode_a5_waveform
log.info("get_events: requesting first event (SUB 1E)")
try:
@@ -603,12 +604,12 @@ class MiniMateClient:
"get_events: 5A full waveform download for key=%s", cur_key.hex()
)
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:
a5_ok = True
_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(
"get_events: 5A decoded %d sample-sets",
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(
frames_data: list[bytes],
event: Event,
compliance_config: Optional["ComplianceConfig"] = None,
) -> None:
"""
Decode the raw 4-channel ADC waveform from a complete set of SUB 5A
@@ -1336,15 +1338,22 @@ def _decode_a5_waveform(
── Frame structure ──────────────────────────────────────────────────────────
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
+8 uint16 BE: total_samples (expected full-record sample-sets)
+16 uint16 BE: pretrig_samples (pre-trigger sample count)
+18 uint8: rectime_seconds (record duration)
Preamble: 6 bytes after the STRT record (confirmed from 4-2-26 blast capture):
bytes 21-22: 0x00 0x00 (null padding)
bytes 23-26: 0xFF × 4 (sync sentinel / alignment marker)
Waveform starts at strt_pos + 27 within db[7:].
+4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
+6..9 next_event_key4 (device pre-allocates next slot)
+10..13 prev_event_key4
+14..15 UNKNOWN (not total_samples)
+16..17 UNKNOWN (not pretrig_samples)
+18 mode byte: 0x46 single-shot, 0x0E continuous
+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):
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
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
and A5[9] terminator):
Confirmed sizes from 4-2-26 blast capture (A5[0..8], metadata at A5[7]):
Frame 0: 934B Frame 1: 963B Frame 2: 946B Frame 3: 960B
Frame 4: 952B Frame 5: 946B Frame 6: 941B Frame 8: 992B
— 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. ─────────────────────────────────────────────────
"""
@@ -1378,27 +1389,48 @@ def _decode_a5_waveform(
# STRT record layout (21 bytes, offsets relative to b'STRT'):
# +0..3 magic b'STRT'
# +8..9 uint16 BE total_samples (full-record expected sample-set count)
# +16..17 uint16 BE pretrig_samples
# +18 uint8 rectime_seconds
# +4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
# +6..9 next_event_key4 (NOT current key — device pre-allocates next)
# +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]
if len(strt) < 21:
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
return
total_samples = struct.unpack_from(">H", strt, 8)[0]
pretrig_samples = struct.unpack_from(">H", strt, 16)[0]
rectime_seconds = strt[18]
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,
log.info(
"_decode_a5_waveform: STRT raw[0:21]: %s",
strt.hex(' '),
)
# 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 ─────────
# global_offset is the cumulative byte count across all frames, used to
# compute the channel alignment at each frame boundary.
@@ -1408,17 +1440,30 @@ def _decode_a5_waveform(
for fi, db in enumerate(frames_data):
w = db[7:]
# A5[0]: waveform begins after the 21-byte STRT record and 6-byte preamble.
# Layout: STRT(21B) + null-pad(2B) + 0xFF sentinel(4B) = 27 bytes total.
# A5[0]: waveform begins immediately after the 21-byte STRT record.
# 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:
sp = w.find(b"STRT")
if sp < 0:
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:", …)
# and no waveform ADC data.
elif fi == 7:
# Metadata frame: contains "Project:", "Client:", etc. strings.
# Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture),
# 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
# Terminator frames have page_key=0x0000 and are excluded upstream
@@ -1490,11 +1535,29 @@ def _decode_a5_waveform(
running_offset += len(wave)
n_decoded_total = len(tran)
log.debug(
"_decode_a5_waveform: decoded %d alignment-corrected sample-sets "
"(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 = {
"Tran": tran,
@@ -1503,6 +1566,64 @@ def _decode_a5_waveform(
"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]:
"""
+42 -4
View File
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS events (
sample_rate INTEGER,
record_type TEXT, -- "single_shot" | "continuous"
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')),
UNIQUE(serial, timestamp)
);
@@ -216,6 +217,17 @@ class SeismoDb:
""")
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
def _iso(dt: Optional[datetime.datetime]) -> Optional[str]:
return dt.isoformat() if dt is not None else None
@@ -282,12 +294,19 @@ class SeismoDb:
*,
serial: str,
session_id: Optional[str] = None,
waveform_blobs: Optional[dict[str, str]] = None,
) -> tuple[int, int]:
"""
Insert triggered events. Silently skips duplicates (serial+timestamp).
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
blobs = waveform_blobs or {}
with self._connect() as conn:
for ev in events:
key = ev._waveform_key.hex() if ev._waveform_key else None
@@ -305,8 +324,9 @@ class SeismoDb:
except Exception:
ts = str(ev.timestamp)
pv = ev.peak_values
pi = ev.project_info
pv = ev.peak_values
pi = ev.project_info
blob = blobs.get(key)
try:
conn.execute(
@@ -315,8 +335,8 @@ class SeismoDb:
(id, serial, waveform_key, session_id, timestamp,
tran_ppv, vert_ppv, long_ppv, peak_vector_sum, mic_ppv,
project, client, operator, sensor_location,
sample_rate, record_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sample_rate, record_type, waveform_blob)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
self._new_id(), serial, key, session_id, ts,
@@ -331,6 +351,7 @@ class SeismoDb:
pi.sensor_location if pi else None,
ev.sample_rate,
ev.record_type,
blob,
),
)
inserted += 1
@@ -387,6 +408,23 @@ class SeismoDb:
)
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 ───────────────────────────────────────────────────────────
def insert_monitor_log(
+50 -2
View File
@@ -662,7 +662,11 @@ def device_event_waveform(
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect()
# stop_after_index avoids downloading events beyond the one requested.
events = client.get_events(full_waveform=True, stop_after_index=index)
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]
return matching[0] if matching else None, info
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:
sample_rate = info.compliance_config.sample_rate
# Recompute rectime_seconds using the actual sample rate now that we have it.
# _decode_a5_waveform used 1024 sps as default; override if device says otherwise.
# strt[18] is a record-mode byte (0x46 / 0x0E), NOT rectime in seconds.
rectime_seconds = ev.rectime_seconds
if (ev.total_samples is not None and ev.pretrig_samples is not None
and sample_rate and sample_rate > 0):
post_trig = max(0, ev.total_samples - ev.pretrig_samples)
rectime_seconds = round(post_trig / sample_rate, 2)
result = {
"index": ev.index,
"record_type": ev.record_type,
"timestamp": _serialise_timestamp(ev.timestamp),
"total_samples": ev.total_samples,
"pretrig_samples": ev.pretrig_samples,
"rectime_seconds": ev.rectime_seconds,
"rectime_seconds": rectime_seconds,
"samples_decoded": samples_decoded,
"sample_rate": sample_rate,
"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}
@app.get("/db/events/{event_id}/waveform")
def db_event_waveform(event_id: str) -> dict:
"""
Return the stored waveform blob for a DB event.
The response shape is identical to GET /device/event/{index}/waveform so the
waveform viewer can consume either source without modification:
- total_samples, pretrig_samples, rectime_seconds, samples_decoded
- sample_rate
- peak_values (tran_in_s, vert_in_s, long_in_s, micl_psi, peak_vector_sum)
- channels ({"Tran": [...], "Vert": [...], "Long": [...], "Mic": [...]})
Returns 404 if the event doesn't exist, 422 if the event exists but has no
stored waveform (downloaded before waveform storage was implemented).
"""
import json as _json
db = _get_db()
found, blob_str = db.get_event_waveform(event_id)
if not found:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
if blob_str is None:
raise HTTPException(
status_code=422,
detail=(
f"Event {event_id} has no stored waveform. "
"Waveform storage requires ACH server v0.11+. "
"Re-download the event from the device to backfill."
),
)
try:
return _json.loads(blob_str)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Waveform blob corrupt: {exc}") from exc
@app.get("/db/monitor_log")
def db_monitor_log(
serial: Optional[str] = Query(None, description="Filter by unit serial"),
+242 -27
View File
@@ -548,6 +548,18 @@
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
.ft-toggle-btn.flagged { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.1); }
.wf-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--accent);
cursor: pointer;
font-size: 13px;
padding: 1px 6px;
line-height: 1;
}
.wf-btn:hover { background: rgba(56,139,253,0.15); border-color: var(--accent); }
.db-empty {
color: var(--text-mute);
font-size: 13px;
@@ -770,6 +782,12 @@
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></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>
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
<div id="debug-content"></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -921,6 +947,7 @@
<table class="db-table" id="hist-table">
<thead>
<tr>
<th></th>
<th>Timestamp</th>
<th>Serial</th>
<th>Tran (in/s)</th>
@@ -1038,6 +1065,7 @@ let eventList = [];
let currentEvent = 0;
let charts = {};
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 CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
@@ -1499,13 +1527,14 @@ function updatePeaksBar(ev) {
async function loadWaveform() {
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;
setStatus('Fetching waveform…', 'loading');
let data;
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); }
data = await r.json();
} catch(e) {
@@ -1514,46 +1543,41 @@ async function loadWaveform() {
return;
}
lastWaveformData = data;
renderWaveform(data);
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 pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const channels = data.channels || {};
// 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`);
// Destroy old chart instances
Object.values(chartsStore).forEach(c => c.destroy());
for (const k in chartsStore) delete chartsStore[k];
if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('empty-state').querySelector('p').textContent =
data.record_type === 'Waveform'
if (emptyEl) {
emptyEl.style.display = 'flex';
const p = emptyEl.querySelector('p');
if (p) p.textContent = data.record_type === 'Waveform'
? 'No samples decoded — check server logs'
: `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;
}
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
document.getElementById('empty-state').style.display = 'none';
const chartsDiv = document.getElementById('charts');
chartsDiv.style.display = 'flex';
chartsDiv.innerHTML = '';
Object.values(charts).forEach(c => c.destroy()); charts = {};
if (emptyEl) emptyEl.style.display = 'none';
chartsEl.style.display = 'flex';
chartsEl.innerHTML = '';
const micPeakPsi = data.peak_values?.micl_psi ?? null;
@@ -1605,9 +1629,9 @@ function renderWaveform(data) {
const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap';
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',
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
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 ────────────────────────────────────────────────────────────────────
let histLoaded = false;
let unitsLoaded = false;
@@ -1745,6 +1827,7 @@ async function loadHistory() {
const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
tr.innerHTML = `
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform"></button></td>
<td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
@@ -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 ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') { closeWfModal(); return; }
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
});
@@ -1935,5 +2095,60 @@ document.getElementById('api-base').value = window.location.origin;
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
});
</script>
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
<div id="wf-modal"
style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(1,4,9,0.88); align-items:flex-start;
justify-content:center; padding:24px; overflow:auto;"
onclick="if(event.target===this)closeWfModal()">
<div style="background:var(--surface); border:1px solid var(--border);
border-radius:8px; width:100%; max-width:1100px;
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
<!-- Header row -->
<div style="display:flex; align-items:center; padding:10px 16px;
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
<div id="wf-modal-title"
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
</div>
<button onclick="closeWfModal()"
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
font-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>
</html>
+254 -90
View File
@@ -175,6 +175,27 @@
}
#connect-btn:hover { background: #2ea043; }
#connect-btn:disabled { background: #21262d; color: #484f58; }
#debug-panel {
display: none;
background: #0d1117;
border-bottom: 1px solid #21262d;
padding: 6px 20px;
font-family: monospace;
font-size: 11px;
color: #6e7681;
line-height: 1.7;
}
#debug-panel.visible { display: block; }
#debug-panel .dp-row { display: flex; gap: 24px; flex-wrap: wrap; }
#debug-panel .dp-ch { color: #8b949e; }
#debug-panel .dp-ch span { color: #c9d1d9; }
#debug-panel .dp-warn { color: #e3b341; }
#debug-toggle {
background: none; border: none; color: #484f58; font-size: 11px;
cursor: pointer; padding: 0; float: right; text-decoration: underline;
}
#debug-toggle:hover { color: #8b949e; }
</style>
</head>
<body>
@@ -193,6 +214,12 @@
</div>
<button id="connect-btn" onclick="connectUnit()">Connect</button>
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer"
title="Re-download from device, bypassing server cache. Check this then click Load Waveform (or checking it will auto-reload if a waveform is already shown).">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
</header>
@@ -219,6 +246,10 @@
</div>
<div id="status-bar">Ready — enter device host and click Connect.</div>
<div id="debug-panel">
<button id="debug-toggle" onclick="document.getElementById('debug-panel').classList.remove('visible')">hide</button>
<div id="debug-content"></div>
</div>
<div id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -244,6 +275,46 @@
let eventList = []; // populated from /device/events after connect
let currentEventIndex = 0;
// ── DB mode: opened via ?db_id=<uuid>&api_base=<url> from History tab ────────
const _urlParams = new URLSearchParams(window.location.search);
const _dbId = _urlParams.get('db_id');
const _dbApiBase = (_urlParams.get('api_base') || '').replace(/\/$/, '');
async function _loadFromDb() {
const apiBase = _dbApiBase || document.getElementById('api-base').value.replace(/\/$/, '');
setStatus('Loading waveform from database…', 'loading');
document.getElementById('unit-bar').style.display = 'none';
// Hide live-device controls — not relevant in DB mode
document.querySelector('header .conn-group').style.display = 'none';
const url = `${apiBase}/db/events/${encodeURIComponent(_dbId)}/waveform`;
let data;
try {
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || resp.statusText);
}
data = await resp.json();
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
return;
}
lastData = data;
renderWaveform(data);
}
// Auto-load when opened with db_id param
window.addEventListener('DOMContentLoaded', () => {
if (_dbId) {
// Pre-fill api-base if provided
if (_dbApiBase) {
document.getElementById('api-base').value = _dbApiBase;
}
_loadFromDb();
}
});
function setStatus(msg, cls = '') {
const bar = document.getElementById('status-bar');
bar.textContent = msg;
@@ -364,7 +435,8 @@
btn.disabled = true;
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;
try {
@@ -404,8 +476,11 @@
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
if (ts) {
bar.textContent = `Event #${data.index} — ${ts.display} `;
const tsDisplay = ts
? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
: null;
if (tsDisplay) {
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
} else {
bar.textContent = `Event #${data.index} `;
}
@@ -413,7 +488,14 @@
appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
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
if (decoded === 0) {
@@ -423,14 +505,20 @@
? 'Waveform decode returned no samples — check server logs'
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
document.getElementById('charts').style.display = 'none';
document.getElementById('debug-panel').classList.remove('visible');
Object.values(charts).forEach(c => c.destroy());
charts = {};
return;
}
// Build time axis (ms)
const times = Array.from({ length: decoded }, (_, i) =>
((i - pretrig) / sr * 1000).toFixed(2)
// Clip to total_samples to exclude zero-padding the device appends beyond
// the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
// decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
const displayCount = (total > 0 && total < decoded) ? total : decoded;
// Build time axis in seconds (matching Blastware event report layout).
const times = Array.from({ length: displayCount }, (_, i) =>
((i - pretrig) / sr).toFixed(3)
);
// Show charts area
@@ -447,6 +535,15 @@
const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
// 0C record peak values (device-computed, authoritative) per channel.
// Keys: live-device endpoint uses tran_in_s/vert_in_s/long_in_s;
// DB blobs created before 2026-04-14 used tran/vert/long — fall back for compat.
const peakValues0C = {
Tran: data.peak_values?.tran_in_s ?? data.peak_values?.tran ?? null,
Vert: data.peak_values?.vert_in_s ?? data.peak_values?.vert ?? null,
Long: data.peak_values?.long_in_s ?? data.peak_values?.long ?? null,
};
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
@@ -455,22 +552,38 @@
const isGeo = ch !== 'Mic';
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
// Clip channel samples to displayCount (same as time axis)
const clippedSamples = samples.length > displayCount
? samples.slice(0, displayCount)
: samples;
// peak0C declared here (function scope) so it is visible in the Chart.js
// config block below (which lives outside the if(isGeo) block).
let peak0C = null;
if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s
// Scale factor for the waveform shape (may need calibration per unit)
const scale = geoRange / 32767;
plotSamples = samples.map(c => c * scale);
const peakIns = Math.max(...plotSamples.map(Math.abs));
plotSamples = clippedSamples.map(c => c * scale);
// Use the device-computed 0C record peak for the label (authoritative).
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
peak0C = peakValues0C[ch];
const peakIns = (peak0C !== null && peak0C !== undefined)
? peak0C
: Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
const peakCounts = Math.max(...samples.map(Math.abs));
const peakCounts = Math.max(...clippedSamples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0)
? Math.abs(micPeakPsi) / peakCounts
: 1.0;
plotSamples = samples.map(c => c * micScale);
plotSamples = clippedSamples.map(c => c * micScale);
const peakPsi = Math.max(...plotSamples.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
@@ -504,88 +617,139 @@
renderData = plotSamples.filter((_, i) => i % step === 0);
}
const chart = new Chart(canvas, {
type: 'line',
data: {
labels: renderTimes,
datasets: [{
data: renderData,
borderColor: color,
borderWidth: 1,
pointRadius: 0,
tension: 0,
let chart;
try {
chart = new Chart(canvas, {
type: 'line',
data: {
labels: renderTimes,
datasets: [{
data: renderData,
borderColor: color,
borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} s`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' s',
},
grid: { color: '#21262d' },
},
y: {
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
// decode artifacts (which inflate autoscale to full range) don't
// squash the actual blast signal into an invisible flat line.
// The 0C peak value is authoritative for the true signal amplitude.
// Guard: only apply if peak0C is a valid finite positive number.
...(isGeo && peak0C !== null && peak0C !== undefined
&& isFinite(peak0C) && peak0C > 0 ? {
min: -(peak0C * 1.4),
max: (peak0C * 1.4),
} : {}),
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
// Find index of the trigger point (t ≥ 0.000 s)
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} ms`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' ms',
},
grid: { color: '#21262d' },
},
y: {
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
});
} catch (err) {
console.error(`Chart.js error for channel ${ch}:`, err);
canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
}
// Find index of t=0
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}],
});
charts[ch] = chart;
if (chart) 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