fix(forward): pair BW ACH ASCII reports using the _ASCII.TXT convention (v1.5.4)

Blastware's official Auto Call Home server writes per-event ASCII
reports as <stem>_<ext>_ASCII.TXT (e.g. N844L20G_630H_ASCII.TXT),
not <binary>.TXT (e.g. N844L20G.630H.TXT).  Versions v1.5.0–v1.5.3
only looked for the latter and silently shipped every binary alone,
so the SFM database lost the per-event Peak Acceleration / Peak
Displacement / ZC Freq / Time of Peak / Peak Vector Sum + time /
sensor self-check fields on every forwarded event.

Fix: pair-finding logic now tries the ACH-convention filename first
and falls back to <binary>.TXT for compatibility with operator-saved
manual exports and existing test fixtures.

  ach_report_name("M529LK44.AB0")    → "M529LK44_AB0_ASCII.TXT"
  legacy_report_name("M529LK44.AB0") → "M529LK44.AB0.TXT"

When both files exist (operator manually saved + ACH auto-exported),
ACH wins because that's the canonical name on modern BW deployments.
Both candidates checked case-insensitively against the cached
directory listing — no extra stat() calls.

6 new unit tests cover the new pairing logic, helper-function
correctness, and the precedence rule.  Total now 31 tests, all green.

Field-deploy note: re-running v1.5.4 on a folder where v1.5.0–v1.5.3
already ran will NOT re-forward historical events — the
sfm_forwarded.json state file remembers them by sha256.  To re-forward
historical events to populate SFM with the now-correctly-paired
reports, delete the state file before starting v1.5.4.
This commit is contained in:
2026-05-10 20:10:38 +00:00
parent a166918a9d
commit 770336e09f
8 changed files with 178 additions and 18 deletions
+63 -11
View File
@@ -96,9 +96,41 @@ def is_event_binary(path: str) -> bool:
return True
def ach_report_name(binary_name: str) -> str:
"""BW ACH report-naming convention.
Blastware's official Auto Call Home server writes per-event ASCII
reports as ``<stem>_<ext>_ASCII.TXT`` — the ``.`` between stem and
ext is replaced with ``_`` and ``_ASCII.TXT`` is appended.
Examples:
``M529LK44.AB0`` → ``M529LK44_AB0_ASCII.TXT``
``N844L20G.630H`` → ``N844L20G_630H_ASCII.TXT``
``H907L1R7.PG0H`` → ``H907L1R7_PG0H_ASCII.TXT``
For a filename without a dot (defensive — shouldn't happen for real
BW events) we still append ``_ASCII.TXT``.
"""
stem, dot, ext = binary_name.rpartition(".")
if not dot:
return binary_name + "_ASCII.TXT"
return stem + "_" + ext + "_ASCII.TXT"
def legacy_report_name(binary_name: str) -> str:
"""Manual-export convention: ``<binary>.TXT`` (e.g. when an operator
saves an event report to text directly from BW's UI rather than
letting ACH auto-export it). Kept as a fallback so the codec-agent
test fixtures (``decode-re/5-8-26/event-c/M529LK44.AB0.TXT``) still
pair correctly."""
return binary_name + ".TXT"
def report_path_for(binary_path: str) -> str:
"""Return the conventional `<binary>.TXT` partner path."""
return binary_path + ".TXT"
"""Legacy entry point — returns the manual-export path. Prefer
:func:`ach_report_name` for new BW deployments. Retained for
backward compatibility with any caller still on the old convention."""
return legacy_report_name(binary_path)
def is_histogram_event(filename: str) -> bool:
@@ -109,9 +141,12 @@ def is_histogram_event(filename: str) -> bool:
return False (the conservative answer — we don't want to suppress
"no report" warnings on potentially-waveform old-firmware events).
Used purely for log clarity — histograms don't get auto-exported BW
ASCII reports, so "no report" on a histogram is not a problem to
flag. Forwarding logic itself doesn't depend on this check.
Used purely for log clarity — when a forward goes through without a
paired TXT, the log distinguishes "histogram, no report expected"
(acceptable: BW may not have written one even though it normally
does for ACH-routed histograms) from "no report ⚠" on a waveform
(more suspicious: BW almost always writes the TXT for waveform events).
Forwarding logic itself doesn't depend on this check.
"""
name = os.path.basename(filename)
ext = os.path.splitext(name)[1].lstrip(".").upper()
@@ -314,12 +349,29 @@ def find_pending_events(
skipped_already_forwarded += 1
continue
# TXT pairing
txt_name = e.name + ".TXT"
# Case-insensitive match on the .TXT suffix
if txt_name not in names:
txt_name_lc = txt_name.lower()
txt_name = next((n for n in names if n.lower() == txt_name_lc), None)
# TXT pairing — try BW ACH convention first
# (<stem>_<ext>_ASCII.TXT) and fall back to the manual-export
# convention (<binary>.TXT). Both checked case-insensitively
# against the cached directory listing. ACH wins when both
# exist — that's the format BW's official ACH server writes.
candidates = [ach_report_name(e.name), legacy_report_name(e.name)]
# Case-insensitive name lookup against the cached set.
names_lc_to_actual = None
txt_name: Optional[str] = None
for cand in candidates:
if cand in names:
txt_name = cand
break
# Build lower-case index lazily — most folders have very few
# TXT files relative to binaries, so the linear scan only
# fires when neither exact-case candidate matches.
if names_lc_to_actual is None:
names_lc_to_actual = {n.lower(): n for n in names}
actual = names_lc_to_actual.get(cand.lower())
if actual:
txt_name = actual
break
txt_path: Optional[str] = None
if txt_name: