2 Commits

Author SHA1 Message Date
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
6 changed files with 93 additions and 17 deletions
+23
View File
@@ -4,6 +4,29 @@ All notable changes to seismo-relay are documented here.
--- ---
## v0.12.1 — 2026-04-15
### Fixed
- **Metadata frame not detected when compliance fields are unconfigured** — the
5A bulk waveform stream contains one metadata frame carrying ASCII compliance
strings. The decoder skipped it only if `b"Project:"` appeared in the frame
payload. On devices where Project/Client/User Name/Seis Loc are all blank, the
device omits those label strings entirely, so the check returned False and the
frame was decoded as ADC waveform samples. The serial number bytes (`"BE11529\0"`)
appeared at sample ~929 followed by `0xFF 0xFF` fill, truncating the second half
of every waveform and producing a flat-line at 0 in/s after ~660 ms.
Fix: `_decode_a5_waveform` now checks a tuple of needles (`_METADATA_FRAME_NEEDLES`)
against the full frame bytes (`db`, not `db[7:]`): `b"Project:"`, `b"Client:"`,
`b"User Name:"`, `b"Seis Loc:"`, `b"Extended Notes"`, and `b"Geo: "`. The geo
threshold label is always present (monitoring cannot operate without a configured
geo trigger level) and is the reliable universal anchor. The same tuple is used
in `read_bulk_waveform_stream` for the `stop_after_metadata` early-exit path.
The log records which needle matched, aiding future diagnosis.
---
## v0.12.0 — 2026-04-13 ## v0.12.0 — 2026-04-13
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
(Sierra Wireless RV50 / RV55). Current version: **v0.12.0**. (Sierra Wireless RV50 / RV55). Current version: **v0.12.1**.
--- ---
+2 -4
View File
@@ -1,4 +1,4 @@
# seismo-relay `v0.12.0` # seismo-relay `v0.12.1`
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.
@@ -263,6 +263,4 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
- [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) - [ ] Vibra
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
+36 -5
View File
@@ -76,6 +76,22 @@ _COMPLIANCE_SLOT_SIZE = 64
_COMPLIANCE_VALUE_OFFSET = 22 _COMPLIANCE_VALUE_OFFSET = 22
_COMPLIANCE_VALUE_MAX = _COMPLIANCE_SLOT_SIZE - _COMPLIANCE_VALUE_OFFSET # 42 _COMPLIANCE_VALUE_MAX = _COMPLIANCE_SLOT_SIZE - _COMPLIANCE_VALUE_OFFSET # 42
# Needles used to identify the 5A metadata frame in _decode_a5_waveform.
# The frame carries compliance-setup ASCII strings embedded in the bulk stream.
# Checking multiple needles is required because devices with unconfigured
# Project/Client/etc. fields omit those label strings entirely — the sole
# reliable anchor then becomes b"Geo: " (always present, geo threshold is
# required for monitoring). We also check db (full rsp.data) rather than
# db[7:] to avoid any off-by-7 miss if a label landed in the prefix bytes.
_METADATA_FRAME_NEEDLES: tuple[bytes, ...] = (
b"Project:",
b"Client:",
b"User Name:",
b"Seis Loc:",
b"Extended Notes",
b"Geo: ",
)
# ── MiniMateClient ──────────────────────────────────────────────────────────── # ── MiniMateClient ────────────────────────────────────────────────────────────
@@ -1456,14 +1472,29 @@ def _decode_a5_waveform(
wave[:24].hex(' '), wave[:24].hex(' '),
) )
# Metadata frame: contains "Project:", "Client:", etc. strings. # Metadata frame: contains compliance-setup ASCII strings.
# Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture), # Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture),
# but confirmed variable position — it appears at whatever chunk index the # but confirmed variable position — it appears at whatever chunk index the
# device places it (observed at fi=6 for desk-thump events 2026-04-14). # device places it (observed at fi=6 for desk-thump events 2026-04-14).
# Skip ANY frame whose raw bytes contain b"Project:" — this is the same #
# anchor used by stop_after_metadata in read_bulk_waveform_stream. # Detection uses MULTIPLE needles against the full frame bytes (db, NOT w)
elif b"Project:" in w: # because:
log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi) # 1. Devices with no Project/Client/etc. configured omit those label strings
# entirely — checking only b"Project:" misses the frame for unconfigured
# units (confirmed 2026-04-15: Brian's device had no fields set, so
# "Project:" was absent and the frame was decoded as ADC samples, causing
# the serial number bytes "BE11529\0" to appear as waveform data).
# 2. Checking db (full rsp.data) rather than w (db[7:]) eliminates any
# off-by-7 risk if a label were to land in the first 7 bytes.
# 3. b"Geo: " is always present (geo threshold is required for monitoring)
# and serves as the universal fallback anchor.
elif any(needle in db for needle in _METADATA_FRAME_NEEDLES):
log.info(
"_decode_a5_waveform: fi=%d skipped (metadata frame, "
"needle=%r)",
fi,
next(n for n in _METADATA_FRAME_NEEDLES if n in db),
)
continue continue
# Terminator frames have page_key=0x0000 and are excluded upstream # Terminator frames have page_key=0x0000 and are excluded upstream
+21 -3
View File
@@ -125,6 +125,20 @@ _BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
# protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004 # protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004
# causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks. # causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks.
# Needles for identifying the 5A metadata frame.
# Devices with no Project/Client/etc. configured omit those label strings,
# so b"Geo: " (geo threshold — always present) is the universal fallback.
# Mirror of _METADATA_FRAME_NEEDLES in client.py; kept here for the
# stop_after_metadata check in read_bulk_waveform_stream.
_METADATA_FRAME_NEEDLES: tuple[bytes, ...] = (
b"Project:",
b"Client:",
b"User Name:",
b"Seis Loc:",
b"Extended Notes",
b"Geo: ",
)
# Default timeout values (seconds). # Default timeout values (seconds).
# MiniMate Plus is a slow device — keep these generous. # MiniMate Plus is a slow device — keep these generous.
DEFAULT_RECV_TIMEOUT = 10.0 DEFAULT_RECV_TIMEOUT = 10.0
@@ -617,9 +631,13 @@ class MiniMateProtocol:
break break
raise raise
# Detect metadata frame using the same multi-needle set used by
# _decode_a5_waveform. Devices with no Project/Client/etc.
# configured omit those label strings; b"Geo: " is the fallback.
_is_metadata = any(n in rsp.data for n in _METADATA_FRAME_NEEDLES)
log.warning( log.warning(
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s", "5A RX chunk=%d page_key=0x%04X data_len=%d is_metadata=%s",
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data, chunk_num, rsp.page_key, len(rsp.data), _is_metadata,
) )
if rsp.page_key == 0x0000: if rsp.page_key == 0x0000:
@@ -629,7 +647,7 @@ class MiniMateProtocol:
frames_data.append(rsp.data) frames_data.append(rsp.data)
if stop_after_metadata and b"Project:" in rsp.data: if stop_after_metadata and _is_metadata:
log.debug("5A A5[%d] metadata found — stopping early", chunk_num) log.debug("5A A5[%d] metadata found — stopping early", chunk_num)
break break
else: else:
+10 -4
View File
@@ -1574,9 +1574,14 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
return; return;
} }
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2)); // Clip to configured record window — device streams extra zero-padded frames
// beyond total_samples; showing them just adds a flat tail to every chart.
const display = data.total_samples ? Math.min(data.total_samples, decoded) : decoded;
const times = Array.from({length: display}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
if (emptyEl) emptyEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
chartsEl.style.display = 'flex'; chartsEl.style.display = 'flex';
chartsEl.style.flexDirection = 'column';
chartsEl.style.gap = '8px';
chartsEl.innerHTML = ''; chartsEl.innerHTML = '';
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
@@ -1590,7 +1595,7 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
if (isGeo) { if (isGeo) {
const scale = geoRange / 32767; const scale = geoRange / 32767;
plotData = samples.map(s => s * scale); plotData = samples.slice(0, display).map(s => s * scale);
// Use the device-recorded peak from the 0C waveform record — authoritative // Use the device-recorded peak from the 0C waveform record — authoritative
// and matches Blastware. Computing from raw samples can catch rogue // and matches Blastware. Computing from raw samples can catch rogue
// near-full-scale values from decoding artifacts. // near-full-scale values from decoding artifacts.
@@ -1601,9 +1606,10 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`; ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4); tickFmt = v => v.toFixed(4);
} else { } else {
const peakCounts = Math.max(...samples.map(Math.abs)); const clippedMic = samples.slice(0, display);
const peakCounts = Math.max(...clippedMic.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0; const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0;
plotData = samples.map(s => s * micScale); plotData = clippedMic.map(s => s * micScale);
const peakPsi = Math.max(...plotData.map(Math.abs)); const peakPsi = Math.max(...plotData.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity; const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL`; peakLabel = `${peakDbl.toFixed(1)} dBL`;