diff --git a/CHANGELOG.md b/CHANGELOG.md index 73cf01c..0a78df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.5.4] - 2026-05-10 + +### Fixed +- **CRITICAL: Pair BW ACH ASCII reports using the `_ASCII.TXT` convention.** Blastware's official Auto Call Home server writes per-event ASCII reports as `__ASCII.TXT` (e.g. `N844L20G_630H_ASCII.TXT`), not `.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. After this fix the watcher tries the ACH-convention filename first and falls back to the manual-export `.TXT` for compatibility with operator-saved exports + existing test fixtures. + +### Changed +- New helper functions `ach_report_name()` and `legacy_report_name()` make the two filename conventions explicit and testable. +- 6 new unit tests covering both pairing conventions, the precedence-when-both-present rule (ACH wins), and helper-function correctness. + +### 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 to pick up the rich metadata — the `sfm_forwarded.json` state file remembers them by sha256 and still considers them forwarded. If you want to re-forward to populate the SFM database with the now-correctly-paired reports for the historical archive, delete the state file before starting v1.5.4. Otherwise the fix only affects events appearing from v1.5.4 onward. + ## [1.5.3] - 2026-05-10 ### Changed diff --git a/README.md b/README.md index bc8a66a..cf92cd1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.5.3 +# Series 3 Watcher v1.5.4 Monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. Runs as a **system tray app** that starts automatically on login, reports heartbeats to terra-view, and self-updates from Gitea. @@ -88,7 +88,7 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here | `UPDATE_SOURCE` | `gitea` (default) or `url` — where to check for updates | | `UPDATE_URL` | Base URL of the update server when `UPDATE_SOURCE = url` (e.g. terra-view URL). The watcher fetches `/api/updates/series3-watcher/version.txt` and `/api/updates/series3-watcher/series3-watcher.exe` from this base. | -### SFM Event Forwarder (v1.5.3+) +### SFM Event Forwarder (v1.5.4+) Forwards each Blastware event binary (and its paired `.TXT` ASCII report when present) to an SFM server's `/db/import/blastware_file` endpoint, where the report is parsed and the rich per-channel stats (PPV, ZC Freq, Time of Peak, Peak Acceleration / Displacement, sensor self-check) land in a searchable database. **Default-off** — existing deployments keep their old behaviour after auto-updating until the operator opts in. @@ -155,7 +155,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.5.3**. +Follows **Semantic Versioning**. Current release: **v1.5.4**. See `CHANGELOG.md` for full history. --- diff --git a/event_forwarder.py b/event_forwarder.py index c2a0c32..d2311ac 100644 --- a/event_forwarder.py +++ b/event_forwarder.py @@ -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 ``__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: ``.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 `.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 + # (__ASCII.TXT) and fall back to the manual-export + # convention (.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: diff --git a/installer.iss b/installer.iss index 19b3c22..7233239 100644 --- a/installer.iss +++ b/installer.iss @@ -3,7 +3,7 @@ [Setup] AppName=Series 3 Watcher -AppVersion=1.5.3 +AppVersion=1.5.4 AppPublisher=Terra-Mechanics Inc. DefaultDirName={pf}\Series3Watcher DefaultGroupName=Series 3 Watcher diff --git a/series3_tray.py b/series3_tray.py index e4f7ac8..527207e 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — System Tray Launcher v1.5.3 +Series 3 Watcher — System Tray Launcher v1.5.4 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw series3_tray.py (no console window) diff --git a/series3_watcher.py b/series3_watcher.py index b595671..af139ed 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -247,7 +247,7 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.5.3" +VERSION = "1.5.4" def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: diff --git a/settings_dialog.py b/settings_dialog.py index 3a67076..cabcdf8 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — Settings Dialog v1.5.3 +Series 3 Watcher — Settings Dialog v1.5.4 Provides a Tkinter settings dialog that doubles as a first-run wizard. diff --git a/test_event_forwarder.py b/test_event_forwarder.py index a734c42..36f9c42 100644 --- a/test_event_forwarder.py +++ b/test_event_forwarder.py @@ -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: .__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: .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)