17 Commits

Author SHA1 Message Date
claude 145074d8b5 fix: change db7 to 5byte frame header from 8byte header 2026-04-16 13:57:26 -04:00
claude a46961c124 fix: waveform decode improved for accuracy.
feat: adds 5a diagnostic script to parse raw binary
2026-04-15 16:36:41 -04:00
claude 8bfebadd46 Revert "fix: improve metadata frame detection and update version to v0.12.1"
This reverts commit ad7b064b67.
2026-04-15 02:01:28 -04:00
claude 257c8ad186 doc: update protocl ref 2026-04-15 01:47:53 -04:00
claude ad7b064b67 fix: improve metadata frame detection and update version to v0.12.1 2026-04-15 01:42:13 -04:00
claude 3dd3c970ab fix: stack modal waveform charts vertically to match live events view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:59:17 -04:00
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
10 changed files with 3815 additions and 2661 deletions
+1138 -946
View File
File diff suppressed because it is too large Load Diff
+268 -268
View File
@@ -1,268 +1,268 @@
# seismo-relay `v0.12.0` # seismo-relay `v0.12.0`
A ground-up replacement for **Blastware** — Instantel's aging Windows-only A ground-up replacement for **Blastware** — Instantel's aging Windows-only
software for managing MiniMate Plus seismographs. software for managing MiniMate Plus seismographs.
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55). over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
> **Status:** Active development. Full read + write + erase + monitoring > **Status:** Active development. Full read + write + erase + monitoring
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server > pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
> handles inbound unit connections, downloads events, and persists everything > handles inbound unit connections, downloads events, and persists everything
> to a SQLite database. SFM REST API exposes device control and DB queries. > to a SQLite database. SFM REST API exposes device control and DB queries.
> See [CHANGELOG.md](CHANGELOG.md) for full version history. > See [CHANGELOG.md](CHANGELOG.md) for full version history.
--- ---
## What's in here ## What's in here
``` ```
seismo-relay/ seismo-relay/
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs) ├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
├── minimateplus/ ← MiniMate Plus client library ├── minimateplus/ ← MiniMate Plus client library
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport │ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
│ ├── protocol.py ← DLE frame layer, SUB command dispatch │ ├── protocol.py ← DLE frame layer, SUB command dispatch
│ ├── client.py ← High-level client (connect, get_events, push_config, …) │ ├── client.py ← High-level client (connect, get_events, push_config, …)
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser │ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, … │ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
├── sfm/ ← SFM REST API server (FastAPI, port 8200) ├── sfm/ ← SFM REST API server (FastAPI, port 8200)
│ ├── server.py ← All device + DB endpoints │ ├── server.py ← All device + DB endpoints
│ ├── database.py ← SeismoDb — SQLite persistence layer │ ├── database.py ← SeismoDb — SQLite persistence layer
│ └── sfm_webapp.html ← Embedded web UI (served at /) │ └── sfm_webapp.html ← Embedded web UI (served at /)
├── bridges/ ├── bridges/
│ ├── ach_server.py ← Inbound ACH call-home server (main production server) │ ├── ach_server.py ← Inbound ACH call-home server (main production server)
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions │ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool) │ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing) │ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
│ ├── gui_bridge.py ← Standalone bridge GUI │ ├── gui_bridge.py ← Standalone bridge GUI
│ └── raw_capture.py ← Simple raw capture tool │ └── raw_capture.py ← Simple raw capture tool
├── parsers/ ├── parsers/
│ ├── s3_analyzer.py ← Session parser, differ, Claude export │ ├── s3_analyzer.py ← Session parser, differ, Claude export
│ ├── gui_analyzer.py ← Standalone analyzer GUI │ ├── gui_analyzer.py ← Standalone analyzer GUI
│ └── frame_db.py ← SQLite frame database │ └── frame_db.py ← SQLite frame database
└── docs/ └── docs/
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec └── instantel_protocol_reference.md ← Reverse-engineered protocol spec
``` ```
--- ---
## Quick start ## Quick start
### ACH inbound server (production) ### ACH inbound server (production)
Listens for inbound unit call-homes, downloads all new events and monitor log Listens for inbound unit call-homes, downloads all new events and monitor log
entries, and writes everything to `bridges/captures/seismo_relay.db`. entries, and writes everything to `bridges/captures/seismo_relay.db`.
```bash ```bash
python bridges/ach_server.py --port 12345 --output bridges/captures/ python bridges/ach_server.py --port 12345 --output bridges/captures/
``` ```
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`. Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
Options: Options:
``` ```
--port N Listen port (default 12345) --port N Listen port (default 12345)
--output DIR Capture directory (default bridges/captures/) --output DIR Capture directory (default bridges/captures/)
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all) --allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
--max-events N Safety cap for first run (default: unlimited) --max-events N Safety cap for first run (default: unlimited)
--clear-after-download Erase device memory after successful download --clear-after-download Erase device memory after successful download
--verbose Debug logging --verbose Debug logging
``` ```
### SFM REST server ### SFM REST server
Exposes device control and DB queries as a REST API. Proxied by terra-view. Exposes device control and DB queries as a REST API. Proxied by terra-view.
```bash ```bash
python sfm/server.py # default: 0.0.0.0:8200 python sfm/server.py # default: 0.0.0.0:8200
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
``` ```
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs` Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
for the interactive API docs. for the interactive API docs.
### Seismo Lab GUI ### Seismo Lab GUI
```bash ```bash
python seismo_lab.py python seismo_lab.py
``` ```
--- ---
## SFM REST API ## SFM REST API
### Live device endpoints ### Live device endpoints
Each call dials the device, does its work, and closes the connection. TCP Each call dials the device, does its work, and closes the connection. TCP
connections are retried once on `ProtocolError` to handle cold-boot timing. connections are retried once on `ProtocolError` to handle cold-boot timing.
**Caching** — frequently-polled endpoints are cached in-process to avoid **Caching** — frequently-polled endpoints are cached in-process to avoid
redundant TCP round-trips: redundant TCP round-trips:
| Method | URL | Cache | | Method | URL | Cache |
|--------|-----|-------| |--------|-----|-------|
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` | | `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
| `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected | | `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected |
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index | | `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
| `GET` | `/device/monitor/status` | 30-second TTL | | `GET` | `/device/monitor/status` | 30-second TTL |
| `POST` | `/device/connect` | — | | `POST` | `/device/connect` | — |
| `POST` | `/device/config` | Writes compliance config; invalidates cache | | `POST` | `/device/config` | Writes compliance config; invalidates cache |
| `POST` | `/device/monitor/start` | Sends SUB 0x96 | | `POST` | `/device/monitor/start` | Sends SUB 0x96 |
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 | | `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
All cached endpoints accept `?force=true` to bypass the cache. All cached endpoints accept `?force=true` to bypass the cache.
Transport query params (supply one set): Transport query params (supply one set):
``` ```
Serial: ?port=COM5&baud=38400 Serial: ?port=COM5&baud=38400
TCP: ?host=1.2.3.4&tcp_port=12345 TCP: ?host=1.2.3.4&tcp_port=12345
``` ```
### DB read endpoints ### DB read endpoints
Query the SQLite database written by `ach_server.py`. All read-only except Query the SQLite database written by `ach_server.py`. All read-only except
`PATCH /db/events/{id}/false_trigger`. `PATCH /db/events/{id}/false_trigger`.
| Method | URL | Description | | Method | URL | Description |
|--------|-----|-------------| |--------|-----|-------------|
| `GET` | `/db/units` | All known serials with summary stats | | `GET` | `/db/units` | All known serials with summary stats |
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) | | `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
| `GET` | `/db/monitor_log` | Monitoring intervals | | `GET` | `/db/monitor_log` | Monitoring intervals |
| `GET` | `/db/sessions` | ACH call-home session history | | `GET` | `/db/sessions` | ACH call-home session history |
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers | | `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
--- ---
## minimateplus library ## minimateplus library
```python ```python
from minimateplus import MiniMateClient from minimateplus import MiniMateClient
from minimateplus.transport import TcpTransport from minimateplus.transport import TcpTransport
# Serial # Serial
client = MiniMateClient(port="COM5") client = MiniMateClient(port="COM5")
# TCP (cellular modem) # TCP (cellular modem)
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0) client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
with client: with client:
# Read # Read
info = client.connect() # DeviceInfo — serial, firmware, compliance config info = client.connect() # DeviceInfo — serial, firmware, compliance config
count = client.count_events() # Number of stored events count = client.count_events() # Number of stored events
keys = client.list_event_keys() # Fast browse walk — event keys only, no download keys = client.list_event_keys() # Fast browse walk — event keys only, no download
events = client.get_events() # Full download: headers + peaks + metadata events = client.get_events() # Full download: headers + peaks + metadata
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records) log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
# Write # Write
client.apply_config( client.apply_config(
sample_rate=1024, sample_rate=1024,
trigger_level_geo=0.5, trigger_level_geo=0.5,
project="Bridge Inspection 2026", project="Bridge Inspection 2026",
client_name="City of Portland", client_name="City of Portland",
operator="B. Harrison", operator="B. Harrison",
) )
# Control # Control
client.start_monitoring() # SUB 0x96 client.start_monitoring() # SUB 0x96
client.stop_monitoring() # SUB 0x97 client.stop_monitoring() # SUB 0x97
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2) client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
``` ```
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`. `get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
existed at record time — not backfilled from the current compliance config. existed at record time — not backfilled from the current compliance config.
--- ---
## Database ## Database
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode). `ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
Three tables, all unit-keyed by serial number: Three tables, all unit-keyed by serial number:
| Table | Key | Contents | | Table | Key | Contents |
|-------|-----|----------| |-------|-----|----------|
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration | | `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag | | `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag |
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold | | `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold |
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
never produce duplicate rows. Post-erase key reuse is handled automatically never produce duplicate rows. Post-erase key reuse is handled automatically
via the high-water mark in `ach_state.json`. via the high-water mark in `ach_state.json`.
--- ---
## Connecting over cellular (RV50 / RV55) ## Connecting over cellular (RV50 / RV55)
Field units connect via Sierra Wireless RV50 or RV55 cellular modems. Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
### Required ACEmanager settings ### Required ACEmanager settings
| Setting | Value | Why | | Setting | Value | Why |
|---------|-------|-----| |---------|-------|-----|
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate | | Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected | | Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake | | **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency | | Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame | | TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect | | TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream | | DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
--- ---
## Protocol quick-reference ## Protocol quick-reference
| Term | Value | Meaning | | Term | Value | Meaning |
|------|-------|---------| |------|-------|---------|
| DLE | `0x10` | Data Link Escape | | DLE | `0x10` | Data Link Escape |
| STX | `0x02` | Start of frame | | STX | `0x02` | Start of frame |
| ETX | `0x03` | End of frame | | ETX | `0x03` | End of frame |
| ACK | `0x41` | Frame-start marker sent before every BW frame | | ACK | `0x41` | Frame-start marker sent before every BW frame |
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload | | DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions) **Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md) Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
--- ---
## Requirements ## Requirements
```bash ```bash
pip install pyserial fastapi uvicorn pip install pyserial fastapi uvicorn
``` ```
Python 3.10+. Tkinter is included with the standard Python installer on Python 3.10+. Tkinter is included with the standard Python installer on
Windows (check "tcl/tk and IDLE" during install). Windows (check "tcl/tk and IDLE" during install).
--- ---
## Virtual COM ports (bridge capture) ## Virtual COM ports (bridge capture)
``` ```
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
``` ```
Use **com0com** or **VSPD** to create the virtual COM pair on Windows. Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
--- ---
## Roadmap ## Roadmap
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata - [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
- [x] Write commands — push compliance config, trigger thresholds, project strings to device - [x] Write commands — push compliance config, trigger thresholds, project strings to device
- [x] Erase all events — confirmed erase sequence from live MITM capture - [x] Erase all events — confirmed erase sequence from live MITM capture
- [x] Monitor control — start/stop monitoring, read battery/memory/status - [x] Monitor control — start/stop monitoring, read battery/memory/status
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals) - [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
- [x] ACH inbound server — accept call-home connections, download events, dedup by key - [x] ACH inbound server — accept call-home connections, download events, dedup by key
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db` - [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
- [x] SFM REST API — device control + DB query endpoints, live device cache - [x] SFM REST API — device control + DB query endpoints, live device cache
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing - [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
- [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first) - [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first)
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object - [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API - [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
+838 -777
View File
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
diagnose_5a_frames.py -- Frame-by-frame diagnostic for SUB 5A waveform streams.
Usage:
python diagnose_5a_frames.py [--host HOST] [--port PORT] [--event INDEX]
Connects to the device, downloads the waveform for the specified event (default 0 =
most recently stored), and prints detailed per-frame info for every A5 response frame:
fi=N | db=NNN B w=NNN B | "Project:" in db=[offsets] in w=[offsets] <-- METADATA if detected
w[0:32] = <hex>
w[-8:] = <hex>
[waveform bytes or ASCII snippet]
Then shows:
- total non-metadata frames, total waveform bytes, total sample-sets decoded
- compliance-config expected vs decoded counts
- sample values at the flat-line onset region (~1700-1820)
- first near-zero run location (|T| < 20 for 10+ consecutive samples)
Run with: python diagnose_5a_frames.py 2>&1 | tee /tmp/diag_output.txt
"""
from __future__ import annotations
import argparse
import logging
import struct
import sys
# -- Setup logging -------------------------------------------------------------
logging.basicConfig(
level=logging.WARNING, # suppress library noise; we print our own output
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
from minimateplus import MiniMateClient
from minimateplus.transport import TcpTransport
log = logging.getLogger("diagnose")
log.setLevel(logging.INFO)
def decode_int16_sets(wave: bytes, n: int = 8) -> list[tuple[int, int, int, int]]:
"""Decode up to n sample-sets from wave bytes as [T, V, L, M] int16 LE."""
sets = []
for i in range(min(n, len(wave) // 8)):
off = i * 8
t = struct.unpack_from("<h", wave, off)[0]
v = struct.unpack_from("<h", wave, off + 2)[0]
l = struct.unpack_from("<h", wave, off + 4)[0]
m = struct.unpack_from("<h", wave, off + 6)[0]
sets.append((t, v, l, m))
return sets
def find_all(data: bytes, needle: bytes) -> list[int]:
"""Return all offsets where needle appears in data."""
positions = []
start = 0
while True:
pos = data.find(needle, start)
if pos < 0:
break
positions.append(pos)
start = pos + 1
return positions
def sep(label: str = "") -> None:
width = 80
if label:
pad = max(0, (width - len(label) - 2) // 2)
print(f"\n{'-' * pad} {label} {'-' * max(0, width - pad - len(label) - 2)}")
else:
print("-" * width)
def diagnose(frames_data: list[bytes], compliance_config=None) -> None:
"""Analyse all A5 frames and print diagnostic info."""
sep("PER-FRAME ANALYSIS")
print(f"Total A5 frames received: {len(frames_data)}")
print()
all_chunks: list[tuple[int, bytes]] = [] # (fi, wave_bytes)
cumulative_wave_bytes = 0
for fi, db in enumerate(frames_data):
w = db[7:] # what _decode_a5_waveform sees (db[7:])
# Find "Project:" in both the full frame data and the w=db[7:] slice
proj_in_db = find_all(db, b"Project:")
proj_in_w = find_all(w, b"Project:")
# The live detector in client.py uses: b"Project:" in w
detected_as_metadata = bool(proj_in_w)
flag = " <-- METADATA (skipped)" if detected_as_metadata else ""
print(f"fi={fi:3d} db={len(db):5d}B w={len(w):5d}B "
f"Project: in db={proj_in_db} in w(db[7:])={proj_in_w}{flag}")
hex_head = w[:32].hex(' ')
hex_tail = w[-8:].hex(' ') if len(w) >= 8 else w.hex(' ')
print(f" w[0:32] = {hex_head}")
print(f" w[-8:] = {hex_tail}")
if fi == 0:
sp = w.find(b"STRT")
if sp >= 0:
strt = w[sp:sp + 21]
print(f" STRT at w[{sp}]: {strt.hex(' ')}")
wave = w[sp + 21:]
if wave:
sets = decode_int16_sets(wave, 4)
print(f" wave[sp+21:] first 4 sets (T,V,L,M): {sets}")
all_chunks.append((fi, wave))
cumulative_wave_bytes += len(wave)
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8}")
else:
print(f" *** STRT NOT FOUND ***")
elif detected_as_metadata:
# Print the ASCII content to confirm this is the real metadata frame
try:
snippet = w.decode("ascii", errors="replace")
# Find the first 200 printable characters
printable = snippet[:200].replace("\x00", ".").replace("\r", "\n").replace("\n", "\n")
print(f" ASCII: {repr(printable[:140])}")
except Exception as e:
print(f" (decode error: {e})")
else:
# Regular chunk: strip 8-byte header
if len(w) >= 8:
wave = w[8:]
all_chunks.append((fi, wave))
cumulative_wave_bytes += len(wave)
sets = decode_int16_sets(wave, 4)
# Count near-zero Tran values
all_sets = decode_int16_sets(wave, len(wave) // 8)
nz = sum(1 for s in all_sets if abs(s[0]) < 20)
print(f" wave[8:] first 4 sets: {sets}")
print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8} "
f"near-zero(|T|<20): {nz}/{len(all_sets)}")
print()
# -- Waveform value analysis ------------------------------------------------
sep("WAVEFORM DECODE")
cc_sr = 1024
cc_rt = None
pretrig = 256
total_expected = 0
if compliance_config:
cc_sr = compliance_config.sample_rate or 1024
cc_rt = compliance_config.record_time
pretrig = int(round(0.25 * cc_sr))
if cc_rt:
total_expected = pretrig + int(round(cc_rt * cc_sr))
print(f"Compliance: sr={cc_sr} sps record_time={cc_rt} s "
f"pretrig={pretrig} total_expected={total_expected}")
else:
print("No compliance config -- using defaults: sr=1024, pretrig=256")
total_wave_bytes = sum(len(w) for _, w in all_chunks)
total_sets_raw = total_wave_bytes // 8
print(f"Non-metadata frames: {len(all_chunks)} "
f"Total wave bytes: {total_wave_bytes} "
f"Raw sample-sets: {total_sets_raw}")
# Alignment-corrected decode (matches _decode_a5_waveform exactly)
tran: list[int] = []
running_offset = 0
for fi, wave in all_chunks:
align = running_offset % 8
skip = (8 - align) % 8
if skip > 0 and skip < len(wave):
usable = wave[skip:]
elif align == 0:
usable = wave
else:
running_offset += len(wave)
continue
n_usable = len(usable) // 8
for i in range(n_usable):
tran.append(struct.unpack_from("<h", usable, i * 8)[0])
running_offset += len(wave)
n_decoded = len(tran)
print(f"Alignment-corrected decoded Tran samples: {n_decoded}")
if compliance_config and cc_rt:
print(f"Expected: {total_expected} Decoded: {n_decoded} "
f"Excess (tail): {max(0, n_decoded - total_expected)}")
print()
print(f"First 16 Tran: {tran[:16]}")
if n_decoded >= 32:
print(f"Last 16 Tran: {tran[-16:]}")
# -- Flat-line onset search -------------------------------------------------
sep("FLAT-LINE ONSET (first run of 10+ consecutive |Tran| < 20)")
run_start = None
run_len = 0
onset_found = False
for i, v in enumerate(tran):
if abs(v) < 20:
if run_start is None:
run_start = i
run_len += 1
else:
if run_len >= 10:
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
print(f" First near-zero run: sample {run_start}-{run_start + run_len - 1} "
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
onset_found = True
break
run_start = None
run_len = 0
else:
if run_len >= 10 and run_start is not None:
t_ms = (run_start - pretrig) * 1000.0 / cc_sr
print(f" Near-zero run at end: sample {run_start}-{n_decoded - 1} "
f"(t={t_ms:.1f}ms post-trigger) length={run_len}")
onset_found = True
if not onset_found:
print(" No near-zero run of 10+ samples found (waveform looks active throughout)")
# Print samples around the expected flat-line onset (~1700-1820)
if n_decoded >= 1700:
print()
print("Tran samples [1700:1820] (10 per line):")
for row_start in range(1700, min(1820, n_decoded), 10):
row = tran[row_start:row_start + 10]
t_ms_row = (row_start - pretrig) * 1000.0 / cc_sr
print(f" [{row_start:4d}] (t={t_ms_row:6.1f}ms): {row}")
else:
print(f" Only {n_decoded} samples decoded -- range 1700-1820 not available")
def main() -> None:
parser = argparse.ArgumentParser(description="Diagnose A5 5A waveform frames")
parser.add_argument("--host", default="63.43.212.232", help="Device IP")
parser.add_argument("--port", type=int, default=9034, help="TCP port")
parser.add_argument("--event", type=int, default=0, help="Event index (0=first stored)")
args = parser.parse_args()
print(f"Connecting to {args.host}:{args.port} ...")
print(f"Target event index: {args.event}")
print()
transport = TcpTransport(args.host, port=args.port)
with MiniMateClient(transport=transport) as client:
info = client.connect()
print(f"Device: serial={info.serial} firmware={info.firmware_version}")
compliance_config = info.compliance_config
if compliance_config:
print(f"Compliance: sample_rate={compliance_config.sample_rate} "
f"record_time={compliance_config.record_time}")
print()
proto = client._proto
assert proto is not None
# -- Walk to the target event ------------------------------------------
log.info("Reading first event key (SUB 1E) ...")
first_key4, first_data8 = proto.read_event_first(token=0)
print(f"First event key: {first_key4.hex()}")
cur_key4 = first_key4
cur_data8 = first_data8
event_idx = 0
while event_idx < args.event:
# 0A required before each 1F to establish device context
proto.read_waveform_header(cur_key4)
next_key4, next_data8 = proto.advance_event(browse=True)
if next_data8[4:8] == b"\x00\x00\x00\x00":
print(f"Only {event_idx + 1} events available; cannot reach index {args.event}")
return
cur_key4 = next_key4
cur_data8 = next_data8
event_idx += 1
print(f" advanced to event {event_idx}: key={cur_key4.hex()}")
print(f"\nDownloading event {args.event}: key={cur_key4.hex()}")
# -- Full download sequence (matches get_events download-mode) ---------
log.info("0A: read_waveform_header ...")
proto.read_waveform_header(cur_key4)
log.info("1E(0xFE): arm device for 5A ...")
proto.read_event_first(token=0xFE)
log.info("0C: read_waveform_record ...")
wfm_raw = proto.read_waveform_record(cur_key4)
print(f"0C waveform record: {len(wfm_raw)} bytes")
log.info("1F(0xFE): arm 5A state machine ...")
arm_key4, _ = proto.advance_event(browse=False)
print(f"1F(arm) returned key: {arm_key4.hex()}")
log.info("POLLx3 ...")
for i in range(3):
proto.poll()
print(f" POLL {i+1}/3 OK")
print(f"\nStarting 5A bulk stream for key={cur_key4.hex()} ...")
frames_data = proto.read_bulk_waveform_stream(
cur_key4,
stop_after_metadata=False,
max_chunks=2048,
)
print(f"5A complete: {len(frames_data)} A5 frames")
print()
# -- Run the diagnostic ------------------------------------------------
diagnose(frames_data, compliance_config)
if __name__ == "__main__":
main()
+193 -60
View File
@@ -448,7 +448,7 @@ class MiniMateClient:
proto.confirm_erase_all() proto.confirm_erase_all()
log.info("delete_all_events: erase confirmed — device memory cleared") log.info("delete_all_events: erase confirmed — device memory cleared")
def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None) -> list[Event]: def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None, compliance_config: Optional["ComplianceConfig"] = None) -> list[Event]:
""" """
Download all stored events from the device using the confirmed Download all stored events from the device using the confirmed
1E 0A 0C 5A 1F event-iterator protocol. 1E 0A 0C 5A 1F event-iterator protocol.
@@ -479,6 +479,7 @@ class MiniMateClient:
ProtocolError: on unrecoverable communication failure. ProtocolError: on unrecoverable communication failure.
""" """
proto = self._require_proto() proto = self._require_proto()
_compliance_config = compliance_config # passed through to _decode_a5_waveform
log.info("get_events: requesting first event (SUB 1E)") log.info("get_events: requesting first event (SUB 1E)")
try: try:
@@ -603,12 +604,12 @@ class MiniMateClient:
"get_events: 5A full waveform download for key=%s", cur_key.hex() "get_events: 5A full waveform download for key=%s", cur_key.hex()
) )
a5_frames = proto.read_bulk_waveform_stream( a5_frames = proto.read_bulk_waveform_stream(
cur_key, stop_after_metadata=False, max_chunks=128 cur_key, stop_after_metadata=False, max_chunks=2048
) )
if a5_frames: if a5_frames:
a5_ok = True a5_ok = True
_decode_a5_metadata_into(a5_frames, ev) _decode_a5_metadata_into(a5_frames, ev)
_decode_a5_waveform(a5_frames, ev) _decode_a5_waveform(a5_frames, ev, compliance_config=_compliance_config)
log.info( log.info(
"get_events: 5A decoded %d sample-sets", "get_events: 5A decoded %d sample-sets",
len((ev.raw_samples or {}).get("Tran", [])), len((ev.raw_samples or {}).get("Tran", [])),
@@ -1311,6 +1312,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
def _decode_a5_waveform( def _decode_a5_waveform(
frames_data: list[bytes], frames_data: list[bytes],
event: Event, event: Event,
compliance_config: Optional["ComplianceConfig"] = None,
) -> None: ) -> None:
""" """
Decode the raw 4-channel ADC waveform from a complete set of SUB 5A Decode the raw 4-channel ADC waveform from a complete set of SUB 5A
@@ -1336,20 +1338,30 @@ def _decode_a5_waveform(
Frame structure Frame structure
A5[0] (probe response): A5[0] (probe response):
db[7:] = [11-byte header] [21-byte STRT record] [6-byte preamble] [waveform ...] db[7:] = [11-byte header] [21-byte STRT record] [waveform ...]
STRT: b'STRT' at offset 11, total 21 bytes STRT: b'STRT' at offset 11, total 21 bytes
+8 uint16 BE: total_samples (expected full-record sample-sets) +4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
+16 uint16 BE: pretrig_samples (pre-trigger sample count) +6..9 next_event_key4 (device pre-allocates next slot)
+18 uint8: rectime_seconds (record duration) +10..13 prev_event_key4
Preamble: 6 bytes after the STRT record (confirmed from 4-2-26 blast capture): +14..15 UNKNOWN (not total_samples)
bytes 21-22: 0x00 0x00 (null padding) +16..17 UNKNOWN (not pretrig_samples)
bytes 23-26: 0xFF × 4 (sync sentinel / alignment marker) +18 mode byte: 0x46 single-shot, 0x0E continuous
Waveform starts at strt_pos + 27 within db[7:]. +19..20 0x00 0x00
Waveform starts immediately at strt_pos + 21 (no preamble).
NOTE: The original 4-2-26 blast capture appeared to show a 6-byte
"preamble" (00 00 ff ff ff ff) after the STRT record, but this was
actually the first ~0.75 sample-sets of quiet pre-trigger ADC data
misread as padding. Confirmed 2026-04-14: bytes 21+ are raw waveform.
total_samples and pretrig_samples are NOT stored in the STRT record;
they are derived from the compliance config (the correct permanent source).
A5[1..N] (chunk responses): A5[1..N] (chunk responses):
db[7:] = [8-byte per-frame header] [waveform bytes ...] db[7:] = [5-byte per-frame header] [waveform bytes ...]
Header: [ctr LE uint16, 0x00 × 6] frame sequence counter Header: [ctr LE uint16, 0x00 × 3] frame sequence counter + 3 null bytes
Waveform starts at byte 8 of db[7:]. Waveform starts at byte 5 of db[7:].
NOTE: Previously documented as 8-byte header INCORRECT. Confirmed 5 bytes
via "Standard Recording Setup" cross-frame continuity test on MITM capture
(4-11-26): A5[5] ends "St", A5[6].w[0:5] = 5 nulls, w[5:]= "andard…" .
Cross-frame alignment Cross-frame alignment
Frame waveform chunk sizes are NOT multiples of 8. Naive concatenation Frame waveform chunk sizes are NOT multiples of 8. Naive concatenation
@@ -1357,11 +1369,13 @@ def _decode_a5_waveform(
cumulative global byte offset; at each new frame, the starting alignment cumulative global byte offset; at each new frame, the starting alignment
within the T,V,L,M cycle is (global_offset % 8). within the T,V,L,M cycle is (global_offset % 8).
Confirmed sizes from 4-2-26 (A5[0..8], skipping A5[7] metadata frame Confirmed sizes from 4-2-26 blast capture (A5[0..8], metadata at A5[7]):
and A5[9] terminator):
Frame 0: 934B Frame 1: 963B Frame 2: 946B Frame 3: 960B Frame 0: 934B Frame 1: 963B Frame 2: 946B Frame 3: 960B
Frame 4: 952B Frame 5: 946B Frame 6: 941B Frame 8: 992B Frame 4: 952B Frame 5: 946B Frame 6: 941B Frame 8: 992B
none are multiples of 8. none are multiples of 8.
NOTE: Metadata frame position is variable at fi==7 for blast events
(4-2-26 capture) and fi==6 for desk-thump events (2026-04-14 confirmed).
The dynamic b"Project:" detection handles both cases.
Modifies event in-place. Modifies event in-place.
""" """
@@ -1378,27 +1392,48 @@ def _decode_a5_waveform(
# STRT record layout (21 bytes, offsets relative to b'STRT'): # STRT record layout (21 bytes, offsets relative to b'STRT'):
# +0..3 magic b'STRT' # +0..3 magic b'STRT'
# +8..9 uint16 BE total_samples (full-record expected sample-set count) # +4..5 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
# +16..17 uint16 BE pretrig_samples # +6..9 next_event_key4 (NOT current key — device pre-allocates next)
# +18 uint8 rectime_seconds # +10..13 prev_event_key4
# +14..15 UNKNOWN — confirmed NOT total_samples (confirmed 2026-04-14)
# +16..17 UNKNOWN — confirmed NOT pretrig_samples (confirmed 2026-04-14)
# +18 mode byte: 0x46='F' single-shot, 0x0E continuous — NOT rectime
# +19..20 0x00 0x00
# Bytes 21+ are raw ADC waveform samples — no preamble.
# total_samples / pretrig_samples are NOT stored in STRT at all.
# The compliance config fallback is the correct permanent source.
strt = w0[strt_pos : strt_pos + 21] strt = w0[strt_pos : strt_pos + 21]
if len(strt) < 21: if len(strt) < 21:
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt)) log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
return return
total_samples = struct.unpack_from(">H", strt, 8)[0] log.info(
pretrig_samples = struct.unpack_from(">H", strt, 16)[0] "_decode_a5_waveform: STRT raw[0:21]: %s",
rectime_seconds = strt[18] strt.hex(' '),
event.total_samples = total_samples
event.pretrig_samples = pretrig_samples
event.rectime_seconds = rectime_seconds
log.debug(
"_decode_a5_waveform: STRT total_samples=%d pretrig=%d rectime=%ds",
total_samples, pretrig_samples, rectime_seconds,
) )
# STRT bytes +14..17 are unknown fields — confirmed NOT total/pretrig_samples
# (2026-04-14). total_samples and pretrig_samples are derived from the
# compliance config, which is the correct permanent source.
_strt_invalid = True
_sample_rate_default = 1024
total_samples = 0
pretrig_samples = 0
rectime_seconds = 0
log.info(
"_decode_a5_waveform: STRT flags=0x%04X next_key=%s prev_key=%s "
"mode=0x%02X → using compliance-config for total/pretrig",
struct.unpack_from(">H", strt, 4)[0],
strt[6:10].hex(),
strt[10:14].hex(),
strt[18],
)
event.total_samples = 0 # will be overwritten by compliance-config fallback below
event.pretrig_samples = 0
event.rectime_seconds = 0
# ── Collect per-frame waveform bytes with global offset tracking ───────── # ── Collect per-frame waveform bytes with global offset tracking ─────────
# global_offset is the cumulative byte count across all frames, used to # global_offset is the cumulative byte count across all frames, used to
# compute the channel alignment at each frame boundary. # compute the channel alignment at each frame boundary.
@@ -1408,27 +1443,65 @@ def _decode_a5_waveform(
for fi, db in enumerate(frames_data): for fi, db in enumerate(frames_data):
w = db[7:] w = db[7:]
# A5[0]: waveform begins after the 21-byte STRT record and 6-byte preamble. # ── Probe frames (fi==0 AND any re-probe the device sends mid-stream) ────
# Layout: STRT(21B) + null-pad(2B) + 0xFF sentinel(4B) = 27 bytes total. # A5[0] always contains the STRT record. For event key 0x01110000,
if fi == 0: # chunk 4 (counter=0x1000) has 0x10 in the counter high byte; the device
sp = w.find(b"STRT") # DLE-decodes the params and sees counter=0x0000 (probe), so it responds
if sp < 0: # with a duplicate probe frame containing the same STRT. The diagnostic
# from 2026-04-15 confirmed this: fi=4 was byte-for-byte identical to fi=0
# (same db length 1101B, same STRT at w[10], same first 32 bytes).
#
# Handling: any frame — not just fi==0 — that contains the STRT magic is
# treated as a probe frame. Waveform starts at strt_pos + 21 (no preamble).
# Re-probe frames are complete duplicates of fi=0 (device re-sends the
# beginning of the event), so their post-STRT waveform bytes are DROPPED
# to avoid injecting duplicate data into the stream.
sp = w.find(b"STRT")
if sp >= 0:
if fi == 0:
wave = w[sp + 21 :]
log.info(
"_decode_a5_waveform: A5[0] probe — STRT at w[%d], "
"waveform starts at sp+21; first 24 wave bytes: %s",
sp, wave[:24].hex(' '),
)
else:
# Re-probe frame: device re-sent probe in response to a chunk
# request whose counter byte happened to be 0x10 (DLE).
# The post-STRT bytes are a duplicate of the initial waveform
# — drop this frame entirely to avoid double-counting data.
log.info(
"_decode_a5_waveform: fi=%d re-probe (STRT at w[%d]) — "
"skipped (duplicate probe response from device)",
fi, sp,
)
continue continue
wave = w[sp + 27 :]
# Frame 7 carries event-time metadata strings ("Project:", "Client:", …) # Metadata frame: contains BOTH "Project:" and "Client:" strings.
# and no waveform ADC data. # Requiring two compliance anchors prevents false positives where ADC
elif fi == 7: # bytes accidentally spell "Project:" (confirmed false positive at fi=15
# in the 2026-04-15 desk-thump download — only "Project:" appeared there,
# not "Client:"). The real metadata frame always contains both.
# This is the same anchor used by stop_after_metadata in
# read_bulk_waveform_stream (which only checks "Project:" — see note
# there about the asymmetry: stopping early is fine with one anchor,
# but skipping a waveform frame requires higher confidence).
elif b"Project:" in w and b"Client:" in w:
log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi)
continue continue
# Terminator frames have page_key=0x0000 and are excluded upstream # Terminator frames have page_key=0x0000 and are excluded upstream
# (read_bulk_waveform_stream returns early on page_key==0). # (read_bulk_waveform_stream returns early on page_key==0).
# No hardcoded frame-index skip here — all non-metadata frames are data. # No hardcoded frame-index skip here — all non-metadata frames are data.
else: else:
# Strip the 8-byte per-frame header (ctr + 6 zero bytes) # Strip the 5-byte per-frame header (ctr[2] + 3 zero bytes + 1 flag byte).
if len(w) < 8: # Confirmed 2026-04-15 via "Standard Recording Setup" cross-frame continuity
# test on MITM capture (4-11-26): A5[5] ends with "St", A5[6].w[0:5] are
# 5 null bytes, w[5:] begins with "andard Recording Setup…" — contiguous iff
# header=5. Previously documented as 8 bytes — INCORRECT.
if len(w) < 5:
continue continue
wave = w[8:] wave = w[5:]
if len(wave) < 2: if len(wave) < 2:
continue continue
@@ -1490,11 +1563,29 @@ def _decode_a5_waveform(
running_offset += len(wave) running_offset += len(wave)
n_decoded_total = len(tran)
log.debug( log.debug(
"_decode_a5_waveform: decoded %d alignment-corrected sample-sets " "_decode_a5_waveform: decoded %d alignment-corrected sample-sets "
"(skipped %d due to frame boundary misalignment)", "(skipped %d due to frame boundary misalignment)",
len(tran), n_sets - len(tran), n_decoded_total, n_sets - n_decoded_total,
) )
# Log first 16 and last 8 samples for every channel — essential for
# validating decoder output and diagnosing flatline / misalignment issues.
_N = min(16, n_decoded_total)
_L = max(0, n_decoded_total - 8)
log.info(
"_decode_a5_waveform: first %d samples — "
"Tran=%s Vert=%s Long=%s Mic=%s",
_N,
tran[:_N], vert[:_N], long_[:_N], mic[:_N],
)
if n_decoded_total > 16:
log.info(
"_decode_a5_waveform: last 8 samples (idx %d%d) — "
"Tran=%s Vert=%s Long=%s Mic=%s",
_L, n_decoded_total - 1,
tran[_L:], vert[_L:], long_[_L:], mic[_L:],
)
event.raw_samples = { event.raw_samples = {
"Tran": tran, "Tran": tran,
@@ -1503,6 +1594,64 @@ def _decode_a5_waveform(
"Mic": mic, "Mic": mic,
} }
# ── Derive pretrig/total from compliance config (always — STRT doesn't store them) ──
# total_samples and pretrig_samples are not stored in the STRT record (confirmed
# 2026-04-14). They are derived from compliance config record_time × sample_rate.
# _strt_invalid is always True; this block always runs.
#
# Formula:
# post_trig = record_time (s) × sample_rate (sps)
# pretrig = decoded_samples post_trig
#
# This gives the pre-trigger window length, which correctly places t=0 in the
# waveform. If compliance_config is not available, leave pretrig=0 (viewer shows
# full waveform starting at t=0 — better than a crash or garbage).
n_decoded = len(tran)
if _strt_invalid:
if compliance_config is not None:
cc_sr = compliance_config.sample_rate or 1024
cc_rt = compliance_config.record_time
# Pre-trigger time is a separate device setting from Record Time.
# Blastware documentation confirms the compliance monitoring standard
# is 0.25 seconds pre-trigger ("the default Time Scale is -0.25 to 1
# second — this negative number accounts for the pre-trigger set for
# compliance monitoring").
# The pre-trigger field has not yet been located in the raw compliance
# config bytes; 0.25 s is used as the best-known default until it is
# decoded. TODO: locate pretrig_time in ComplianceConfig bytes.
_PRETRIG_SECONDS_DEFAULT = 0.25
derived_pretrig = int(round(_PRETRIG_SECONDS_DEFAULT * cc_sr))
if cc_rt and cc_rt > 0:
post_trig_samples = int(round(cc_rt * cc_sr))
# Clip total to pretrig + post_trig so the viewer doesn't show the
# zero-padded tail frames the device appends beyond the record window.
event.total_samples = derived_pretrig + post_trig_samples
event.pretrig_samples = derived_pretrig
event.rectime_seconds = int(round(cc_rt))
log.info(
"_decode_a5_waveform: pretrig=%d (%.2fs compliance default) "
"post_trig=%d total=%d record_time=%.1fs "
"sr=%d sps decoded=%d samples",
derived_pretrig, _PRETRIG_SECONDS_DEFAULT,
post_trig_samples, event.total_samples,
cc_rt, cc_sr, n_decoded,
)
else:
event.total_samples = n_decoded
event.pretrig_samples = derived_pretrig
log.warning(
"_decode_a5_waveform: STRT invalid, compliance config missing "
"record_time — pretrig=%d (%.2fs default), total=decoded %d",
derived_pretrig, _PRETRIG_SECONDS_DEFAULT, n_decoded,
)
else:
event.total_samples = n_decoded
log.warning(
"_decode_a5_waveform: STRT invalid, no compliance config available "
"— pretrig left as 0, total set to decoded count %d",
n_decoded,
)
def _extract_record_type(data: bytes) -> Optional[str]: def _extract_record_type(data: bytes) -> Optional[str]:
""" """
@@ -2127,20 +2276,4 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
# Payload length varies (4649 bytes) but the battery/memory block is always # Payload length varies (4649 bytes) but the battery/memory block is always
# the last 10 bytes. No checksum byte — it was stripped by S3FrameParser. # the last 10 bytes. No checksum byte — it was stripped by S3FrameParser.
# #
# section[-10:-8] battery × 100 uint16 BE 0x02A8 = 6.80 V # section[-1
# section[-8 :-4] memory_total uint32 BE ≈ 960 KB on BE11529
# section[-4:] memory_free uint32 BE decreases as events fill
#
# Confirmed stable across IDLE (46b), MONITORING (48-49b) variants.
if len(section) >= 10:
batt_raw = struct.unpack(">H", section[-10:-8])[0]
battery_v = batt_raw / 100.0
memory_total = struct.unpack(">I", section[-8:-4])[0]
memory_free = struct.unpack(">I", section[-4:])[0]
return MonitorStatus(
is_monitoring=is_monitoring,
battery_v=battery_v,
memory_total=memory_total,
memory_free=memory_free,
)
+524 -486
View File
File diff suppressed because it is too large Load Diff
+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: with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect() info = client.connect()
# stop_after_index avoids downloading events beyond the one requested. # stop_after_index avoids downloading events beyond the one requested.
events = client.get_events(full_waveform=True, stop_after_index=index) events = client.get_events(
full_waveform=True,
stop_after_index=index,
compliance_config=info.compliance_config if info else None,
)
matching = [ev for ev in events if ev.index == index] matching = [ev for ev in events if ev.index == index]
return matching[0] if matching else None, info return matching[0] if matching else None, info
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host)) ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
@@ -689,13 +693,22 @@ def device_event_waveform(
if sample_rate is None and info.compliance_config: if sample_rate is None and info.compliance_config:
sample_rate = info.compliance_config.sample_rate sample_rate = info.compliance_config.sample_rate
# Recompute rectime_seconds using the actual sample rate now that we have it.
# _decode_a5_waveform used 1024 sps as default; override if device says otherwise.
# strt[18] is a record-mode byte (0x46 / 0x0E), NOT rectime in seconds.
rectime_seconds = ev.rectime_seconds
if (ev.total_samples is not None and ev.pretrig_samples is not None
and sample_rate and sample_rate > 0):
post_trig = max(0, ev.total_samples - ev.pretrig_samples)
rectime_seconds = round(post_trig / sample_rate, 2)
result = { result = {
"index": ev.index, "index": ev.index,
"record_type": ev.record_type, "record_type": ev.record_type,
"timestamp": _serialise_timestamp(ev.timestamp), "timestamp": _serialise_timestamp(ev.timestamp),
"total_samples": ev.total_samples, "total_samples": ev.total_samples,
"pretrig_samples": ev.pretrig_samples, "pretrig_samples": ev.pretrig_samples,
"rectime_seconds": ev.rectime_seconds, "rectime_seconds": rectime_seconds,
"samples_decoded": samples_decoded, "samples_decoded": samples_decoded,
"sample_rate": sample_rate, "sample_rate": sample_rate,
"peak_values": _serialise_peak_values(ev.peak_values), "peak_values": _serialise_peak_values(ev.peak_values),
@@ -1006,6 +1019,41 @@ def db_set_false_trigger(
return {"status": "ok", "event_id": event_id, "false_trigger": value} return {"status": "ok", "event_id": event_id, "false_trigger": value}
@app.get("/db/events/{event_id}/waveform")
def db_event_waveform(event_id: str) -> dict:
"""
Return the stored waveform blob for a DB event.
The response shape is identical to GET /device/event/{index}/waveform so the
waveform viewer can consume either source without modification:
- total_samples, pretrig_samples, rectime_seconds, samples_decoded
- sample_rate
- peak_values (tran_in_s, vert_in_s, long_in_s, micl_psi, peak_vector_sum)
- channels ({"Tran": [...], "Vert": [...], "Long": [...], "Mic": [...]})
Returns 404 if the event doesn't exist, 422 if the event exists but has no
stored waveform (downloaded before waveform storage was implemented).
"""
import json as _json
db = _get_db()
found, blob_str = db.get_event_waveform(event_id)
if not found:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
if blob_str is None:
raise HTTPException(
status_code=422,
detail=(
f"Event {event_id} has no stored waveform. "
"Waveform storage requires ACH server v0.11+. "
"Re-download the event from the device to backfill."
),
)
try:
return _json.loads(blob_str)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Waveform blob corrupt: {exc}") from exc
@app.get("/db/monitor_log") @app.get("/db/monitor_log")
def db_monitor_log( def db_monitor_log(
serial: Optional[str] = Query(None, description="Filter by unit serial"), serial: Optional[str] = Query(None, description="Filter by unit serial"),
+222 -32
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;
@@ -770,6 +782,12 @@
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button> <button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button> <button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button> <button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></div> <div class="event-chips" id="event-chips"></div>
</div> </div>
@@ -781,6 +799,14 @@
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div> <div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div>
</div> </div>
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
<div id="debug-content"></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;"> <div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="empty-state"> <div id="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -921,6 +947,7 @@
<table class="db-table" id="hist-table"> <table class="db-table" id="hist-table">
<thead> <thead>
<tr> <tr>
<th></th>
<th>Timestamp</th> <th>Timestamp</th>
<th>Serial</th> <th>Serial</th>
<th>Tran (in/s)</th> <th>Tran (in/s)</th>
@@ -1038,6 +1065,7 @@ let eventList = [];
let currentEvent = 0; let currentEvent = 0;
let charts = {}; let charts = {};
let geoRange = 6.206; let geoRange = 6.206;
let lastWaveformData = null; // last successfully rendered waveform payload
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' }; const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
@@ -1499,13 +1527,14 @@ function updatePeaksBar(ev) {
async function loadWaveform() { async function loadWaveform() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; } if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent; const idx = currentEvent;
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
document.getElementById('load-btn').disabled = true; document.getElementById('load-btn').disabled = true;
setStatus('Fetching waveform…', 'loading'); setStatus('Fetching waveform…', 'loading');
let data; let data;
try { try {
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`); const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json(); data = await r.json();
} catch(e) { } catch(e) {
@@ -1514,52 +1543,57 @@ 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; // Clip display to total_samples (pretrig + post_trig from compliance config).
// The device bulk-streams zero-padded (0xFF = -1) frames beyond the configured
// record window; without clipping these appear as a flat line at ~0 in/s past
// the end of the actual recording. Confirmed 2026-04-15: a 36-frame 5A stream
// for a 3.25s event (total_samples=3328) contained 19 trailing all-0xFF frames
// (2457 extra samples) that caused a visible flat-line in the waveform display.
const total = (data.total_samples && data.total_samples > 0) ? data.total_samples : decoded;
const display = Math.min(decoded, total);
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: display}, (_, 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.style.flexDirection = 'column';
chartsDiv.innerHTML = ''; chartsEl.style.gap = '8px';
Object.values(charts).forEach(c => c.destroy()); charts = {}; chartsEl.innerHTML = '';
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) { for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch]; const samples = (channels[ch] || []).slice(0, display);
if (!samples || samples.length === 0) continue; if (samples.length === 0) continue;
const isGeo = ch !== 'Mic'; const isGeo = ch !== 'Mic';
let plotData, peakLabel, yUnit, ttFmt, tickFmt; let plotData, peakLabel, yUnit, ttFmt, tickFmt;
@@ -1605,9 +1639,9 @@ function renderWaveform(data) {
const cw = document.createElement('div'); const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap'; cw.className = 'chart-canvas-wrap';
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap); cw.appendChild(canvas); wrap.appendChild(cw); chartsEl.appendChild(wrap);
charts[ch] = new Chart(canvas, { chartsStore[ch] = new Chart(canvas, {
type: 'line', type: 'line',
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] }, data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
options: { options: {
@@ -1642,6 +1676,64 @@ function renderWaveform(data) {
} }
} }
function renderWaveform(data) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
lastWaveformData = data;
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
updateDebugPanel(data);
}
// ── Debug panel population ─────────────────────────────────────────────────────
function _fillDebugPanel(data, dbg, cont) {
if (!dbg || !cont) return;
const channels = data.channels || {};
const pv = data.peak_values || {};
const scale = geoRange / 32767;
const geoChans = ['Tran', 'Vert', 'Long'];
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
for (const ch of [...geoChans, 'Mic']) {
const raw = (channels[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
}
html += '</div>';
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
if (nullPeaks.length > 0) {
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
}
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
cont.innerHTML = html;
dbg.style.display = 'block';
}
function updateDebugPanel(data) {
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
}
// ── DB tabs ──────────────────────────────────────────────────────────────────── // ── DB tabs ────────────────────────────────────────────────────────────────────
let histLoaded = false; let histLoaded = false;
let unitsLoaded = false; let unitsLoaded = false;
@@ -1745,6 +1837,7 @@ async function loadHistory() {
const pvs = ev.peak_vector_sum; const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0); const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
tr.innerHTML = ` tr.innerHTML = `
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform"></button></td>
<td>${_fmtTs(ev.timestamp)}</td> <td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td> <td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td> <td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
@@ -1919,9 +2012,86 @@ async function loadSessions() {
} }
} }
// ── DB waveform modal ─────────────────────────────────────────────────────────
let modalCharts = {};
async function openDbWaveformModal(id) {
const modal = document.getElementById('wf-modal');
const titleEl = document.getElementById('wf-modal-title');
const chartsEl = document.getElementById('wf-modal-charts');
const emptyEl = document.getElementById('wf-modal-empty');
const peaksEl = document.getElementById('wf-modal-peaks');
const debugEl = document.getElementById('wf-modal-debug');
// Show modal in loading state
titleEl.textContent = 'Loading…';
peaksEl.classList.remove('visible');
if (debugEl) debugEl.style.display = 'none';
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.querySelector('p').textContent = 'Loading waveform…';
modal.style.display = 'flex';
let data;
try {
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
return;
}
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
if (data.peak_values) {
const pv = data.peak_values;
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
}
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
const sr = data.sample_rate || 1024;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const pretrig = data.pretrig_samples || 0;
let tsStr = '';
if (data.timestamp) {
const tsDisplay = typeof data.timestamp === 'object'
? (data.timestamp.display || String(data.timestamp))
: new Date(data.timestamp).toLocaleString();
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
}
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
// Peaks bar
const pv = data.peak_values || {};
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
peaksEl.classList.add('visible');
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
}
function closeWfModal() {
const modal = document.getElementById('wf-modal');
if (!modal || modal.style.display === 'none') return;
modal.style.display = 'none';
// Destroy chart instances to free canvas memory
Object.values(modalCharts).forEach(c => c.destroy());
for (const k in modalCharts) delete modalCharts[k];
}
// ── Keyboard shortcuts ───────────────────────────────────────────────────────── // ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') { closeWfModal(); return; }
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); } if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); } if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
}); });
@@ -1935,5 +2105,25 @@ 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>
</body>
</html> <!-- ── 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-si
+254 -90
View File
@@ -175,6 +175,27 @@
} }
#connect-btn:hover { background: #2ea043; } #connect-btn:hover { background: #2ea043; }
#connect-btn:disabled { background: #21262d; color: #484f58; } #connect-btn:disabled { background: #21262d; color: #484f58; }
#debug-panel {
display: none;
background: #0d1117;
border-bottom: 1px solid #21262d;
padding: 6px 20px;
font-family: monospace;
font-size: 11px;
color: #6e7681;
line-height: 1.7;
}
#debug-panel.visible { display: block; }
#debug-panel .dp-row { display: flex; gap: 24px; flex-wrap: wrap; }
#debug-panel .dp-ch { color: #8b949e; }
#debug-panel .dp-ch span { color: #c9d1d9; }
#debug-panel .dp-warn { color: #e3b341; }
#debug-toggle {
background: none; border: none; color: #484f58; font-size: 11px;
cursor: pointer; padding: 0; float: right; text-decoration: underline;
}
#debug-toggle:hover { color: #8b949e; }
</style> </style>
</head> </head>
<body> <body>
@@ -193,6 +214,12 @@
</div> </div>
<button id="connect-btn" onclick="connectUnit()">Connect</button> <button id="connect-btn" onclick="connectUnit()">Connect</button>
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button> <button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer"
title="Re-download from device, bypassing server cache. Check this then click Load Waveform (or checking it will auto-reload if a waveform is already shown).">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button> <button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button> <button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
</header> </header>
@@ -219,6 +246,10 @@
</div> </div>
<div id="status-bar">Ready — enter device host and click Connect.</div> <div id="status-bar">Ready — enter device host and click Connect.</div>
<div id="debug-panel">
<button id="debug-toggle" onclick="document.getElementById('debug-panel').classList.remove('visible')">hide</button>
<div id="debug-content"></div>
</div>
<div id="empty-state"> <div id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -244,6 +275,46 @@
let eventList = []; // populated from /device/events after connect let eventList = []; // populated from /device/events after connect
let currentEventIndex = 0; let currentEventIndex = 0;
// ── DB mode: opened via ?db_id=<uuid>&api_base=<url> from History tab ────────
const _urlParams = new URLSearchParams(window.location.search);
const _dbId = _urlParams.get('db_id');
const _dbApiBase = (_urlParams.get('api_base') || '').replace(/\/$/, '');
async function _loadFromDb() {
const apiBase = _dbApiBase || document.getElementById('api-base').value.replace(/\/$/, '');
setStatus('Loading waveform from database…', 'loading');
document.getElementById('unit-bar').style.display = 'none';
// Hide live-device controls — not relevant in DB mode
document.querySelector('header .conn-group').style.display = 'none';
const url = `${apiBase}/db/events/${encodeURIComponent(_dbId)}/waveform`;
let data;
try {
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || resp.statusText);
}
data = await resp.json();
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
return;
}
lastData = data;
renderWaveform(data);
}
// Auto-load when opened with db_id param
window.addEventListener('DOMContentLoaded', () => {
if (_dbId) {
// Pre-fill api-base if provided
if (_dbApiBase) {
document.getElementById('api-base').value = _dbApiBase;
}
_loadFromDb();
}
});
function setStatus(msg, cls = '') { function setStatus(msg, cls = '') {
const bar = document.getElementById('status-bar'); const bar = document.getElementById('status-bar');
bar.textContent = msg; bar.textContent = msg;
@@ -364,7 +435,8 @@
btn.disabled = true; btn.disabled = true;
setStatus('Fetching waveform…', 'loading'); setStatus('Fetching waveform…', 'loading');
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`; const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
let data; let data;
try { try {
@@ -404,8 +476,11 @@
bar.innerHTML = ''; bar.innerHTML = '';
bar.className = 'ok'; bar.className = 'ok';
const ts = data.timestamp; const ts = data.timestamp;
if (ts) { const tsDisplay = ts
bar.textContent = `Event #${data.index} — ${ts.display} `; ? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
: null;
if (tsDisplay) {
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
} else { } else {
bar.textContent = `Event #${data.index} `; bar.textContent = `Event #${data.index} `;
} }
@@ -413,7 +488,14 @@
appendMeta('sr', `${sr} sps`); appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`); appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
appendMeta('pretrig', pretrig); appendMeta('pretrig', pretrig);
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`); // rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
// _decode_a5_waveform. Also show the compliance config record_time for reference.
const cfgRt = unitInfo?.compliance_config?.record_time;
const strtRt = data.rectime_seconds;
const rtStr = (strtRt !== null && strtRt !== undefined)
? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
: (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
appendMeta('rectime', rtStr);
// No waveform data — show a clear reason instead of empty charts // No waveform data — show a clear reason instead of empty charts
if (decoded === 0) { if (decoded === 0) {
@@ -423,14 +505,20 @@
? 'Waveform decode returned no samples — check server logs' ? 'Waveform decode returned no samples — check server logs'
: `Record type "${recType}" — waveform decode not yet supported for this mode`; : `Record type "${recType}" — waveform decode not yet supported for this mode`;
document.getElementById('charts').style.display = 'none'; document.getElementById('charts').style.display = 'none';
document.getElementById('debug-panel').classList.remove('visible');
Object.values(charts).forEach(c => c.destroy()); Object.values(charts).forEach(c => c.destroy());
charts = {}; charts = {};
return; return;
} }
// Build time axis (ms) // Clip to total_samples to exclude zero-padding the device appends beyond
const times = Array.from({ length: decoded }, (_, i) => // the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
((i - pretrig) / sr * 1000).toFixed(2) // decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
const displayCount = (total > 0 && total < decoded) ? total : decoded;
// Build time axis in seconds (matching Blastware event report layout).
const times = Array.from({ length: displayCount }, (_, i) =>
((i - pretrig) / sr).toFixed(3)
); );
// Show charts area // Show charts area
@@ -447,6 +535,15 @@
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
// 0C record peak values (device-computed, authoritative) per channel.
// Keys: live-device endpoint uses tran_in_s/vert_in_s/long_in_s;
// DB blobs created before 2026-04-14 used tran/vert/long — fall back for compat.
const peakValues0C = {
Tran: data.peak_values?.tran_in_s ?? data.peak_values?.tran ?? null,
Vert: data.peak_values?.vert_in_s ?? data.peak_values?.vert ?? null,
Long: data.peak_values?.long_in_s ?? data.peak_values?.long ?? null,
};
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) { for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch]; const samples = channels[ch];
if (!samples || samples.length === 0) continue; if (!samples || samples.length === 0) continue;
@@ -455,22 +552,38 @@
const isGeo = ch !== 'Mic'; const isGeo = ch !== 'Mic';
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt; let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
// Clip channel samples to displayCount (same as time axis)
const clippedSamples = samples.length > displayCount
? samples.slice(0, displayCount)
: samples;
// peak0C declared here (function scope) so it is visible in the Chart.js
// config block below (which lives outside the if(isGeo) block).
let peak0C = null;
if (isGeo) { if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s // Geo channels: counts × (range / 32767) → in/s
// Scale factor for the waveform shape (may need calibration per unit)
const scale = geoRange / 32767; const scale = geoRange / 32767;
plotSamples = samples.map(c => c * scale); plotSamples = clippedSamples.map(c => c * scale);
const peakIns = Math.max(...plotSamples.map(Math.abs));
// Use the device-computed 0C record peak for the label (authoritative).
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
peak0C = peakValues0C[ch];
const peakIns = (peak0C !== null && peak0C !== undefined)
? peak0C
: Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`; peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s'; yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`; tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4); tickFmt = v => v.toFixed(4);
} else { } else {
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header // Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
const peakCounts = Math.max(...samples.map(Math.abs)); const peakCounts = Math.max(...clippedSamples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0) const micScale = (micPeakPsi !== null && peakCounts > 0)
? Math.abs(micPeakPsi) / peakCounts ? Math.abs(micPeakPsi) / peakCounts
: 1.0; : 1.0;
plotSamples = samples.map(c => c * micScale); plotSamples = clippedSamples.map(c => c * micScale);
const peakPsi = Math.max(...plotSamples.map(Math.abs)); const peakPsi = Math.max(...plotSamples.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity; const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`; peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
@@ -504,88 +617,139 @@
renderData = plotSamples.filter((_, i) => i % step === 0); renderData = plotSamples.filter((_, i) => i % step === 0);
} }
const chart = new Chart(canvas, { let chart;
type: 'line', try {
data: { chart = new Chart(canvas, {
labels: renderTimes, type: 'line',
datasets: [{ data: {
data: renderData, labels: renderTimes,
borderColor: color, datasets: [{
borderWidth: 1, data: renderData,
pointRadius: 0, borderColor: color,
tension: 0, borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} s`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' s',
},
grid: { color: '#21262d' },
},
y: {
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
// decode artifacts (which inflate autoscale to full range) don't
// squash the actual blast signal into an invisible flat line.
// The 0C peak value is authoritative for the true signal amplitude.
// Guard: only apply if peak0C is a valid finite positive number.
...(isGeo && peak0C !== null && peak0C !== undefined
&& isFinite(peak0C) && peak0C > 0 ? {
min: -(peak0C * 1.4),
max: (peak0C * 1.4),
} : {}),
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
// Find index of the trigger point (t ≥ 0.000 s)
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}], }],
}, });
options: { } catch (err) {
animation: false, console.error(`Chart.js error for channel ${ch}:`, err);
responsive: true, canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
maintainAspectRatio: false, }
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} ms`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' ms',
},
grid: { color: '#21262d' },
},
y: {
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
// Find index of t=0 if (chart) charts[ch] = chart;
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}],
});
charts[ch] = chart;
} }
// ── Debug panel: raw ADC counts + decode diagnostics ────────────────────
// Shows the first 8 decoded ADC counts per channel and whether peak values
// came from the 0C record (authoritative) or from Math.max fallback.
// Useful for diagnosing channel misalignment without touching server logs.
const dbg = document.getElementById('debug-panel');
const dbgContent = document.getElementById('debug-content');
const geoChans = ['Tran', 'Vert', 'Long'];
const rawChans = channels;
const scale = geoRange / 32767;
let dbgHtml = '<div class="dp-row">';
// per-channel first-8 raw counts
for (const ch of [...geoChans, 'Mic']) {
const raw = (rawChans[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const p0c = peakValues0C?.[ch] ?? null;
const src = (ch !== 'Mic' && p0c !== null) ? `0C=${p0c.toFixed(4)}` : `Math.max=${(maxAbs*scale).toFixed(4)}`;
dbgHtml += `<div class="dp-ch">${ch} raw[0:8]: <span>${raw.join(', ')}</span> peak src: <span>${src}</span></div>`;
}
dbgHtml += '</div>';
// warn if peak0C was null for any geo channel
const nullPeaks = geoChans.filter(ch => (peakValues0C?.[ch] ?? null) === null);
if (nullPeaks.length > 0) {
dbgHtml += `<div class="dp-warn">⚠ peak0C null for: ${nullPeaks.join(', ')} — using Math.max fallback (check Force reload + Load Waveform)</div>`;
}
// summary line
dbgHtml += `<div>decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange}</div>`;
dbgContent.innerHTML = dbgHtml;
dbg.classList.add('visible');
} }
// Auto-detect API base from wherever this page was served from // Auto-detect API base from wherever this page was served from
Binary file not shown.