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
+96
View File
@@ -50,6 +50,22 @@ class TestIsEventBinary(unittest.TestCase):
"something.h5", "noise.json"]:
self.assertFalse(ef.is_event_binary(name), name)
def test_ach_report_name(self):
"""BW ACH convention: <stem>.<ext> → <stem>_<ext>_ASCII.TXT"""
cases = [
("M529LK44.AB0", "M529LK44_AB0_ASCII.TXT"),
("N844L20G.630H", "N844L20G_630H_ASCII.TXT"),
("I145L64P.GD0W", "I145L64P_GD0W_ASCII.TXT"),
("H907L1R7.PG0H", "H907L1R7_PG0H_ASCII.TXT"),
]
for binary, expected in cases:
self.assertEqual(ef.ach_report_name(binary), expected, binary)
def test_legacy_report_name(self):
"""Manual-export convention: <binary>.TXT"""
self.assertEqual(ef.legacy_report_name("M529LK44.AB0"),
"M529LK44.AB0.TXT")
def test_is_histogram_event(self):
# 4-char extension ending in H = histogram
for name in ["H907L1R7.PG0H", "S353L4H0.8S0H", "P036L318.C80H"]:
@@ -139,6 +155,86 @@ class TestFindPendingEvents(unittest.TestCase):
self.assertEqual(os.path.basename(pending[0][0]), "M529LK44.AB0")
self.assertEqual(os.path.basename(pending[0][1]), "M529LK44.AB0.TXT")
def test_pairs_with_ach_underscore_ascii_naming(self):
"""BW ACH writes M529LK44.AB0 + M529LK44_AB0_ASCII.TXT. The
watcher must pair these even though the .TXT filename doesn't
carry a literal copy of the binary's name."""
with tempfile.TemporaryDirectory() as tmp:
tmp_p = Path(tmp)
self._make(tmp_p, "N844L20G.630H", age_seconds=120, content=b"binary")
self._make(tmp_p, "N844L20G_630H_ASCII.TXT", age_seconds=100, content=b"report")
state = ef.ForwardState(str(tmp_p / "fwd.json"))
pending = ef.find_pending_events(
str(tmp_p), state,
max_age_days=30,
quiescence_seconds=5,
missing_report_grace_seconds=60,
)
self.assertEqual(len(pending), 1)
self.assertEqual(os.path.basename(pending[0][0]), "N844L20G.630H")
self.assertEqual(os.path.basename(pending[0][1]),
"N844L20G_630H_ASCII.TXT")
def test_pairs_with_ach_underscore_ascii_naming_for_waveform(self):
"""Same as above but for new-firmware waveform events
(extension ends in W)."""
with tempfile.TemporaryDirectory() as tmp:
tmp_p = Path(tmp)
self._make(tmp_p, "I145L64P.GD0W", age_seconds=120, content=b"binary")
self._make(tmp_p, "I145L64P_GD0W_ASCII.TXT", age_seconds=100, content=b"report")
state = ef.ForwardState(str(tmp_p / "fwd.json"))
pending = ef.find_pending_events(
str(tmp_p), state,
max_age_days=30,
quiescence_seconds=5,
missing_report_grace_seconds=60,
)
self.assertEqual(len(pending), 1)
self.assertEqual(os.path.basename(pending[0][1]),
"I145L64P_GD0W_ASCII.TXT")
def test_pairing_prefers_ach_naming_when_both_exist(self):
"""If a folder has BOTH conventions (operator manually exported
AND ACH also auto-exported), ACH wins because that's the
canonical name in modern BW deployments."""
with tempfile.TemporaryDirectory() as tmp:
tmp_p = Path(tmp)
self._make(tmp_p, "M529LK44.AB0", age_seconds=120, content=b"binary")
# Both partner files present
self._make(tmp_p, "M529LK44.AB0.TXT", age_seconds=100, content=b"manual")
self._make(tmp_p, "M529LK44_AB0_ASCII.TXT", age_seconds=100, content=b"ach")
state = ef.ForwardState(str(tmp_p / "fwd.json"))
pending = ef.find_pending_events(
str(tmp_p), state,
max_age_days=30,
quiescence_seconds=5,
missing_report_grace_seconds=60,
)
self.assertEqual(len(pending), 1)
self.assertEqual(os.path.basename(pending[0][1]),
"M529LK44_AB0_ASCII.TXT")
def test_pairing_falls_back_to_dot_txt_when_ach_absent(self):
"""If only the manual-export filename exists, the legacy
convention still works (preserves codec-agent test fixtures)."""
with tempfile.TemporaryDirectory() as tmp:
tmp_p = Path(tmp)
self._make(tmp_p, "M529LK44.AB0", age_seconds=120, content=b"binary")
self._make(tmp_p, "M529LK44.AB0.TXT", age_seconds=100, content=b"manual")
state = ef.ForwardState(str(tmp_p / "fwd.json"))
pending = ef.find_pending_events(
str(tmp_p), state,
max_age_days=30,
quiescence_seconds=5,
missing_report_grace_seconds=60,
)
self.assertEqual(len(pending), 1)
self.assertEqual(os.path.basename(pending[0][1]), "M529LK44.AB0.TXT")
def test_skips_if_already_forwarded(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_p = Path(tmp)