Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 145074d8b5 | |||
| a46961c124 | |||
| 8bfebadd46 | |||
| 257c8ad186 | |||
| ad7b064b67 | |||
| 3dd3c970ab | |||
| 6a0f0ae2e4 | |||
| bbd574e7d5 | |||
| 727bfed5c4 | |||
| 8d0537389d |
@@ -1,268 +1,268 @@
|
||||
# seismo-relay `v0.12.0`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing MiniMate Plus seismographs.
|
||||
|
||||
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
||||
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
||||
|
||||
> **Status:** Active development. Full read + write + erase + monitoring
|
||||
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
||||
> handles inbound unit connections, downloads events, and persists everything
|
||||
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||
|
||||
---
|
||||
|
||||
## What's in here
|
||||
|
||||
```
|
||||
seismo-relay/
|
||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||
│
|
||||
├── minimateplus/ ← MiniMate Plus client library
|
||||
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
||||
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
||||
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
||||
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
||||
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
||||
│
|
||||
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
||||
│ ├── server.py ← All device + DB endpoints
|
||||
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
||||
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
||||
│
|
||||
├── bridges/
|
||||
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
||||
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
||||
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
||||
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||
│ └── raw_capture.py ← Simple raw capture tool
|
||||
│
|
||||
├── parsers/
|
||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||
│ └── frame_db.py ← SQLite frame database
|
||||
│
|
||||
└── docs/
|
||||
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### ACH inbound server (production)
|
||||
|
||||
Listens for inbound unit call-homes, downloads all new events and monitor log
|
||||
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
Options:
|
||||
```
|
||||
--port N Listen port (default 12345)
|
||||
--output DIR Capture directory (default bridges/captures/)
|
||||
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
||||
--max-events N Safety cap for first run (default: unlimited)
|
||||
--clear-after-download Erase device memory after successful download
|
||||
--verbose Debug logging
|
||||
```
|
||||
|
||||
### SFM REST server
|
||||
|
||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||
|
||||
```bash
|
||||
python sfm/server.py # default: 0.0.0.0:8200
|
||||
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`
|
||||
for the interactive API docs.
|
||||
|
||||
### Seismo Lab GUI
|
||||
|
||||
```bash
|
||||
python seismo_lab.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SFM REST API
|
||||
|
||||
### Live device endpoints
|
||||
|
||||
Each call dials the device, does its work, and closes the connection. TCP
|
||||
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
||||
|
||||
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
||||
redundant TCP round-trips:
|
||||
|
||||
| Method | URL | Cache |
|
||||
|--------|-----|-------|
|
||||
| `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/event/{idx}/waveform` | Permanent per event index |
|
||||
| `GET` | `/device/monitor/status` | 30-second TTL |
|
||||
| `POST` | `/device/connect` | — |
|
||||
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
||||
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
||||
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
||||
|
||||
All cached endpoints accept `?force=true` to bypass the cache.
|
||||
|
||||
Transport query params (supply one set):
|
||||
```
|
||||
Serial: ?port=COM5&baud=38400
|
||||
TCP: ?host=1.2.3.4&tcp_port=12345
|
||||
```
|
||||
|
||||
### DB read endpoints
|
||||
|
||||
Query the SQLite database written by `ach_server.py`. All read-only except
|
||||
`PATCH /db/events/{id}/false_trigger`.
|
||||
|
||||
| Method | URL | Description |
|
||||
|--------|-----|-------------|
|
||||
| `GET` | `/db/units` | All known serials with summary stats |
|
||||
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
||||
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
||||
| `GET` | `/db/sessions` | ACH call-home session history |
|
||||
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
||||
|
||||
---
|
||||
|
||||
## minimateplus library
|
||||
|
||||
```python
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
# Serial
|
||||
client = MiniMateClient(port="COM5")
|
||||
|
||||
# TCP (cellular modem)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||
|
||||
with client:
|
||||
# Read
|
||||
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
||||
count = client.count_events() # Number of stored events
|
||||
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
||||
events = client.get_events() # Full download: headers + peaks + metadata
|
||||
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
||||
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
||||
|
||||
# Write
|
||||
client.apply_config(
|
||||
sample_rate=1024,
|
||||
trigger_level_geo=0.5,
|
||||
project="Bridge Inspection 2026",
|
||||
client_name="City of Portland",
|
||||
operator="B. Harrison",
|
||||
)
|
||||
|
||||
# Control
|
||||
client.start_monitoring() # SUB 0x96
|
||||
client.stop_monitoring() # SUB 0x97
|
||||
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
||||
```
|
||||
|
||||
`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
|
||||
existed at record time — not backfilled from the current compliance config.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
||||
Three tables, all unit-keyed by serial number:
|
||||
|
||||
| Table | Key | Contents |
|
||||
|-------|-----|----------|
|
||||
| `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 |
|
||||
| `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
|
||||
never produce duplicate rows. Post-erase key reuse is handled automatically
|
||||
via the high-water mark in `ach_state.json`.
|
||||
|
||||
---
|
||||
|
||||
## Connecting over cellular (RV50 / RV55)
|
||||
|
||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
||||
|
||||
### Required ACEmanager settings
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
||||
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
||||
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
||||
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
||||
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
||||
|
||||
---
|
||||
|
||||
## Protocol quick-reference
|
||||
|
||||
| Term | Value | Meaning |
|
||||
|------|-------|---------|
|
||||
| DLE | `0x10` | Data Link Escape |
|
||||
| STX | `0x02` | Start of frame |
|
||||
| ETX | `0x03` | End of frame |
|
||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||
|
||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install pyserial fastapi uvicorn
|
||||
```
|
||||
|
||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||
Windows (check "tcl/tk and IDLE" during install).
|
||||
|
||||
---
|
||||
|
||||
## Virtual COM ports (bridge capture)
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [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] Erase all events — confirmed erase sequence from live MITM capture
|
||||
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
||||
- [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] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
|
||||
- [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
|
||||
- [ ] 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
|
||||
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||
# seismo-relay `v0.12.0`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing MiniMate Plus seismographs.
|
||||
|
||||
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
|
||||
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
|
||||
|
||||
> **Status:** Active development. Full read + write + erase + monitoring
|
||||
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
|
||||
> handles inbound unit connections, downloads events, and persists everything
|
||||
> to a SQLite database. SFM REST API exposes device control and DB queries.
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
|
||||
|
||||
---
|
||||
|
||||
## What's in here
|
||||
|
||||
```
|
||||
seismo-relay/
|
||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||
│
|
||||
├── minimateplus/ ← MiniMate Plus client library
|
||||
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
|
||||
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
|
||||
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
|
||||
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
|
||||
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
|
||||
│
|
||||
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
|
||||
│ ├── server.py ← All device + DB endpoints
|
||||
│ ├── database.py ← SeismoDb — SQLite persistence layer
|
||||
│ └── sfm_webapp.html ← Embedded web UI (served at /)
|
||||
│
|
||||
├── bridges/
|
||||
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
|
||||
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
|
||||
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
|
||||
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
|
||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||
│ └── raw_capture.py ← Simple raw capture tool
|
||||
│
|
||||
├── parsers/
|
||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||
│ └── frame_db.py ← SQLite frame database
|
||||
│
|
||||
└── docs/
|
||||
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### ACH inbound server (production)
|
||||
|
||||
Listens for inbound unit call-homes, downloads all new events and monitor log
|
||||
entries, and writes everything to `bridges/captures/seismo_relay.db`.
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
Options:
|
||||
```
|
||||
--port N Listen port (default 12345)
|
||||
--output DIR Capture directory (default bridges/captures/)
|
||||
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
|
||||
--max-events N Safety cap for first run (default: unlimited)
|
||||
--clear-after-download Erase device memory after successful download
|
||||
--verbose Debug logging
|
||||
```
|
||||
|
||||
### SFM REST server
|
||||
|
||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||
|
||||
```bash
|
||||
python sfm/server.py # default: 0.0.0.0:8200
|
||||
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`
|
||||
for the interactive API docs.
|
||||
|
||||
### Seismo Lab GUI
|
||||
|
||||
```bash
|
||||
python seismo_lab.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SFM REST API
|
||||
|
||||
### Live device endpoints
|
||||
|
||||
Each call dials the device, does its work, and closes the connection. TCP
|
||||
connections are retried once on `ProtocolError` to handle cold-boot timing.
|
||||
|
||||
**Caching** — frequently-polled endpoints are cached in-process to avoid
|
||||
redundant TCP round-trips:
|
||||
|
||||
| Method | URL | Cache |
|
||||
|--------|-----|-------|
|
||||
| `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/event/{idx}/waveform` | Permanent per event index |
|
||||
| `GET` | `/device/monitor/status` | 30-second TTL |
|
||||
| `POST` | `/device/connect` | — |
|
||||
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
|
||||
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
|
||||
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
|
||||
|
||||
All cached endpoints accept `?force=true` to bypass the cache.
|
||||
|
||||
Transport query params (supply one set):
|
||||
```
|
||||
Serial: ?port=COM5&baud=38400
|
||||
TCP: ?host=1.2.3.4&tcp_port=12345
|
||||
```
|
||||
|
||||
### DB read endpoints
|
||||
|
||||
Query the SQLite database written by `ach_server.py`. All read-only except
|
||||
`PATCH /db/events/{id}/false_trigger`.
|
||||
|
||||
| Method | URL | Description |
|
||||
|--------|-----|-------------|
|
||||
| `GET` | `/db/units` | All known serials with summary stats |
|
||||
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
|
||||
| `GET` | `/db/monitor_log` | Monitoring intervals |
|
||||
| `GET` | `/db/sessions` | ACH call-home session history |
|
||||
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
|
||||
|
||||
---
|
||||
|
||||
## minimateplus library
|
||||
|
||||
```python
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
# Serial
|
||||
client = MiniMateClient(port="COM5")
|
||||
|
||||
# TCP (cellular modem)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||
|
||||
with client:
|
||||
# Read
|
||||
info = client.connect() # DeviceInfo — serial, firmware, compliance config
|
||||
count = client.count_events() # Number of stored events
|
||||
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
|
||||
events = client.get_events() # Full download: headers + peaks + metadata
|
||||
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
|
||||
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
|
||||
|
||||
# Write
|
||||
client.apply_config(
|
||||
sample_rate=1024,
|
||||
trigger_level_geo=0.5,
|
||||
project="Bridge Inspection 2026",
|
||||
client_name="City of Portland",
|
||||
operator="B. Harrison",
|
||||
)
|
||||
|
||||
# Control
|
||||
client.start_monitoring() # SUB 0x96
|
||||
client.stop_monitoring() # SUB 0x97
|
||||
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
|
||||
```
|
||||
|
||||
`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
|
||||
existed at record time — not backfilled from the current compliance config.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
|
||||
Three tables, all unit-keyed by serial number:
|
||||
|
||||
| Table | Key | Contents |
|
||||
|-------|-----|----------|
|
||||
| `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 |
|
||||
| `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
|
||||
never produce duplicate rows. Post-erase key reuse is handled automatically
|
||||
via the high-water mark in `ach_state.json`.
|
||||
|
||||
---
|
||||
|
||||
## Connecting over cellular (RV50 / RV55)
|
||||
|
||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
|
||||
|
||||
### Required ACEmanager settings
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
|
||||
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
|
||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
|
||||
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
|
||||
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
|
||||
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
|
||||
|
||||
---
|
||||
|
||||
## Protocol quick-reference
|
||||
|
||||
| Term | Value | Meaning |
|
||||
|------|-------|---------|
|
||||
| DLE | `0x10` | Data Link Escape |
|
||||
| STX | `0x02` | Start of frame |
|
||||
| ETX | `0x03` | End of frame |
|
||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||
|
||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
```bash
|
||||
pip install pyserial fastapi uvicorn
|
||||
```
|
||||
|
||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||
Windows (check "tcl/tk and IDLE" during install).
|
||||
|
||||
---
|
||||
|
||||
## Virtual COM ports (bridge capture)
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [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] Erase all events — confirmed erase sequence from live MITM capture
|
||||
- [x] Monitor control — start/stop monitoring, read battery/memory/status
|
||||
- [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] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
|
||||
- [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
|
||||
- [ ] 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
|
||||
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||
|
||||
+838
-835
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
+61
-42
@@ -1356,9 +1356,12 @@ def _decode_a5_waveform(
|
||||
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 ...]
|
||||
Header: [ctr LE uint16, 0x00 × 6] — frame sequence counter
|
||||
Waveform starts at byte 8 of db[7:].
|
||||
db[7:] = [5-byte per-frame header] [waveform bytes ...]
|
||||
Header: [ctr LE uint16, 0x00 × 3] — frame sequence counter + 3 null bytes
|
||||
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 ────────────────────────────────────────────────────
|
||||
Frame waveform chunk sizes are NOT multiples of 8. Naive concatenation
|
||||
@@ -1366,11 +1369,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. ─────────────────────────────────────────────────
|
||||
"""
|
||||
@@ -1438,35 +1443,65 @@ def _decode_a5_waveform(
|
||||
for fi, db in enumerate(frames_data):
|
||||
w = db[7:]
|
||||
|
||||
# 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:
|
||||
# ── Probe frames (fi==0 AND any re-probe the device sends mid-stream) ────
|
||||
# A5[0] always contains the STRT record. For event key 0x01110000,
|
||||
# chunk 4 (counter=0x1000) has 0x10 in the counter high byte; the device
|
||||
# DLE-decodes the params and sees counter=0x0000 (probe), so it responds
|
||||
# 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
|
||||
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 BOTH "Project:" and "Client:" strings.
|
||||
# Requiring two compliance anchors prevents false positives where ADC
|
||||
# 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
|
||||
|
||||
# Terminator frames have page_key=0x0000 and are excluded upstream
|
||||
# (read_bulk_waveform_stream returns early on page_key==0).
|
||||
# No hardcoded frame-index skip here — all non-metadata frames are data.
|
||||
else:
|
||||
# Strip the 8-byte per-frame header (ctr + 6 zero bytes)
|
||||
if len(w) < 8:
|
||||
# Strip the 5-byte per-frame header (ctr[2] + 3 zero bytes + 1 flag byte).
|
||||
# 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
|
||||
wave = w[8:]
|
||||
wave = w[5:]
|
||||
|
||||
if len(wave) < 2:
|
||||
continue
|
||||
@@ -2241,20 +2276,4 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
||||
# Payload length varies (46–49 bytes) but the battery/memory block is always
|
||||
# the last 10 bytes. No checksum byte — it was stripped by S3FrameParser.
|
||||
#
|
||||
# section[-10:-8] battery × 100 uint16 BE 0x02A8 = 6.80 V
|
||||
# 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,
|
||||
)
|
||||
# section[-1
|
||||
+524
-524
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1028,7 +1028,7 @@ def db_event_waveform(event_id: str) -> dict:
|
||||
waveform viewer can consume either source without modification:
|
||||
- total_samples, pretrig_samples, rectime_seconds, samples_decoded
|
||||
- sample_rate
|
||||
- peak_values (tran, vert, long, micl_psi, peak_vector_sum)
|
||||
- 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
|
||||
|
||||
+209
-34
@@ -782,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 reload
|
||||
</label>
|
||||
<div class="event-chips" id="event-chips"></div>
|
||||
</div>
|
||||
|
||||
@@ -793,6 +799,14 @@
|
||||
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
@@ -1051,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' };
|
||||
|
||||
@@ -1512,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) {
|
||||
@@ -1527,52 +1543,57 @@ 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;
|
||||
// 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 || {};
|
||||
|
||||
// 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 = {};
|
||||
const times = Array.from({length: display}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
chartsEl.style.display = 'flex';
|
||||
chartsEl.style.flexDirection = 'column';
|
||||
chartsEl.style.gap = '8px';
|
||||
chartsEl.innerHTML = '';
|
||||
|
||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||
|
||||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||
const samples = channels[ch];
|
||||
if (!samples || samples.length === 0) continue;
|
||||
const samples = (channels[ch] || []).slice(0, display);
|
||||
if (samples.length === 0) continue;
|
||||
|
||||
const isGeo = ch !== 'Mic';
|
||||
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
|
||||
@@ -1618,9 +1639,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: {
|
||||
@@ -1655,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 ────────────────────────────────────────────────────────────────────
|
||||
let histLoaded = false;
|
||||
let unitsLoaded = false;
|
||||
@@ -1757,9 +1836,8 @@ async function loadHistory() {
|
||||
const tr = document.createElement('tr');
|
||||
const pvs = ev.peak_vector_sum;
|
||||
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
||||
const waveformUrl = `${api()}/waveform?db_id=${encodeURIComponent(ev.id)}&api_base=${encodeURIComponent(api())}`;
|
||||
tr.innerHTML = `
|
||||
<td><button class="wf-btn" onclick="window.open('${waveformUrl}','_blank')" title="View waveform">〜</button></td>
|
||||
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform">〜</button></td>
|
||||
<td>${_fmtTs(ev.timestamp)}</td>
|
||||
<td class="td-key">${ev.serial ?? '—'}</td>
|
||||
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
|
||||
@@ -1934,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 ─────────────────────────────────────────────────────────
|
||||
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(); }
|
||||
});
|
||||
@@ -1950,5 +2105,25 @@ document.getElementById('api-base').value = window.location.origin;
|
||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
||||
});
|
||||
</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
|
||||
@@ -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,8 +214,10 @@
|
||||
</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">
|
||||
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb" />
|
||||
<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 reload
|
||||
</label>
|
||||
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
||||
@@ -223,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">
|
||||
@@ -478,6 +505,7 @@
|
||||
? '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;
|
||||
@@ -507,11 +535,13 @@
|
||||
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
|
||||
// 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 ?? null,
|
||||
Vert: data.peak_values?.vert_in_s ?? null,
|
||||
Long: data.peak_values?.long_in_s ?? null,
|
||||
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)) {
|
||||
@@ -685,6 +715,41 @@
|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user