From ad7b064b67ac93b1b4bd5b0e65499f7a2ab337d9 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Wed, 15 Apr 2026 01:42:13 -0400 Subject: [PATCH] fix: improve metadata frame detection and update version to v0.12.1 --- CHANGELOG.md | 23 ++++++++++++++++++++++ CLAUDE.md | 2 +- README.md | 6 ++---- minimateplus/client.py | 41 +++++++++++++++++++++++++++++++++++----- minimateplus/protocol.py | 24 ++++++++++++++++++++--- sfm/sfm_webapp.html | 12 ++++++++---- 6 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index defc04f..ef56e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 94f0db0..93668bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for 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**. --- diff --git a/README.md b/README.md index 1c86de0..47aac6f 100644 --- a/README.md +++ b/README.md @@ -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 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] 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 +- [ ] Vibra \ No newline at end of file diff --git a/minimateplus/client.py b/minimateplus/client.py index 6973214..277f70e 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -76,6 +76,22 @@ _COMPLIANCE_SLOT_SIZE = 64 _COMPLIANCE_VALUE_OFFSET = 22 _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 ──────────────────────────────────────────────────────────── @@ -1456,14 +1472,29 @@ def _decode_a5_waveform( 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), # but confirmed variable position — it appears at whatever chunk index the # device places it (observed at fi=6 for desk-thump events 2026-04-14). - # Skip ANY frame whose raw bytes contain b"Project:" — this is the same - # anchor used by stop_after_metadata in read_bulk_waveform_stream. - elif b"Project:" in w: - log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi) + # + # Detection uses MULTIPLE needles against the full frame bytes (db, NOT w) + # because: + # 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 # Terminator frames have page_key=0x0000 and are excluded upstream diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index 35e99cf..f3d5dfe 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -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 # 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). # MiniMate Plus is a slow device — keep these generous. DEFAULT_RECV_TIMEOUT = 10.0 @@ -617,9 +631,13 @@ class MiniMateProtocol: break 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( - "5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s", - chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data, + "5A RX chunk=%d page_key=0x%04X data_len=%d is_metadata=%s", + chunk_num, rsp.page_key, len(rsp.data), _is_metadata, ) if rsp.page_key == 0x0000: @@ -629,7 +647,7 @@ class MiniMateProtocol: 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) break else: diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 3078fa4..3433df5 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -1574,7 +1574,10 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { 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'; chartsEl.style.display = 'flex'; chartsEl.style.flexDirection = 'column'; @@ -1592,7 +1595,7 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { if (isGeo) { 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 // and matches Blastware. Computing from raw samples can catch rogue // near-full-scale values from decoding artifacts. @@ -1603,9 +1606,10 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`; tickFmt = v => v.toFixed(4); } 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; - plotData = samples.map(s => s * micScale); + plotData = clippedMic.map(s => s * micScale); const peakPsi = Math.max(...plotData.map(Math.abs)); const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity; peakLabel = `${peakDbl.toFixed(1)} dBL`;