diff --git a/CHANGELOG.md b/CHANGELOG.md index b4362eb..ba94a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [6-25-26] — v1.6.1 + +Reliability fixes for the tray app's auto-updater and scan logging. + +### Fixed +- **Auto-updater no longer dies when Settings are saved.** Saving the Settings dialog restarts the watcher thread via `_restart_watcher`, which sets and then replaces the shared `stop_event`. Because the updater loop keyed off that *same* event, saving settings would terminate the auto-updater thread (a race — it could survive one save and die on the next). The watcher kept running and reporting heartbeats, but the agent silently stopped checking for / applying updates until the tray app was relaunched. The updater now keys off its own app-lifetime event (`_app_stop`) that watcher restarts never touch; it stops only on Exit or when applying an update. (Same fix as thor-watcher 0.4.1.) +- **Unsniffable `.MLG` files are logged once, not every scan.** A `.MLG` file the watcher couldn't extract a unit ID from was never cached, so every scan (~every 5 min) re-sniffed *and* re-emitted its `[unsniffable-recent]` log line. With a few hundred such files in a long-lived Blastware autocall folder that meant tens of thousands of duplicate lines per day (a 10-day-old log had grown to ~600K lines). The scan now caches the unsniffable result, so each file is sniffed and logged once per session — and the redundant per-scan header reads are gone. +- Added `test_scan_latest.py` (unsniffable cached / logged-once + happy-path sniff) and `test_series3_tray.py` (updater survives a watcher restart; stops on exit). + ## [6-25-26] — v1.6.0 Optional dual-send mirror so a standby server stays a live replica. diff --git a/README.md b/README.md index f2a30e6..1c511eb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.6.0 +# Series 3 Watcher v1.6.1 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. @@ -188,7 +188,7 @@ where the corresponding server-side work lives. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.6.0**. +Follows **Semantic Versioning**. Current release: **v1.6.1**. See `CHANGELOG.md` for full history. --- diff --git a/installer.iss b/installer.iss index 00748ad..abadb56 100644 --- a/installer.iss +++ b/installer.iss @@ -3,7 +3,7 @@ [Setup] AppName=Series 3 Watcher -AppVersion=1.6.0 +AppVersion=1.6.1 AppPublisher=Terra-Mechanics Inc. DefaultDirName={pf}\Series3Watcher DefaultGroupName=Series 3 Watcher diff --git a/series3_tray.py b/series3_tray.py index bb5e64c..b8fa182 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — System Tray Launcher v1.6.0 +Series 3 Watcher — System Tray Launcher v1.6.1 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw series3_tray.py (no console window) @@ -335,7 +335,8 @@ def _show_cancel_message(): class WatcherTray: def __init__(self): self.state = {} - self.stop_event = threading.Event() + self.stop_event = threading.Event() # watcher lifecycle; replaced on restart + self._app_stop = threading.Event() # app lifetime; only set on exit/update self._watcher_thread = None self._icon = None # Lock guards _rebuild_menu calls from the updater thread @@ -394,6 +395,7 @@ class WatcherTray: subprocess.Popen(["explorer", HERE]) def _exit(self, icon, item): + self._app_stop.set() self.stop_event.set() icon.stop() @@ -459,7 +461,7 @@ class WatcherTray: last_status = None update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks) - while not self.stop_event.is_set(): + while not self._app_stop.is_set(): icon_status = self._tray_status() if self._icon is not None: @@ -487,7 +489,7 @@ class WatcherTray: self._do_update(url) return # exit loop; swap bat will relaunch - self.stop_event.wait(timeout=10) + self._app_stop.wait(timeout=10) def _do_update(self, download_url=None): """Notify tray icon then apply update. If url is None, fetch it first.""" @@ -502,6 +504,7 @@ class WatcherTray: success = apply_update(download_url) if success: + self._app_stop.set() self.stop_event.set() if self._icon is not None: self._icon.stop() diff --git a/series3_watcher.py b/series3_watcher.py index cc65306..804c796 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -237,13 +237,16 @@ def scan_latest( uid = cached[1] else: uid = sniff_unit_from_mlg(fpath, header_bytes) - if not uid: - # If unsniffable but very recent, log for later inspection - if (recent_cutoff is not None) and (mtime >= recent_cutoff): - if logger: - logger("[unsniffable-recent] {}".format(fpath)) - continue # skip file if no unit ID found in header + # Cache the result either way — including unsniffable (uid=None) — + # so the same file isn't re-sniffed and re-logged on every scan. cache[fpath] = (mtime, uid) + if (not uid) and (recent_cutoff is not None) and (mtime >= recent_cutoff): + # Log once, on first sight, for later inspection. + if logger: + logger("[unsniffable-recent] {}".format(fpath)) + + if not uid: + continue # no unit ID in header — skip (cached above, won't re-log) if (uid not in latest) or (mtime > latest[uid]["mtime"]): latest[uid] = {"mtime": mtime, "fname": e.name, "path": fpath} @@ -253,7 +256,7 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.6.0" +VERSION = "1.6.1" def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: diff --git a/settings_dialog.py b/settings_dialog.py index c353a12..0a5b99e 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — Settings Dialog v1.6.0 +Series 3 Watcher — Settings Dialog v1.6.1 Provides a Tkinter settings dialog that doubles as a first-run wizard. diff --git a/test_scan_latest.py b/test_scan_latest.py new file mode 100644 index 0000000..5ea78e9 --- /dev/null +++ b/test_scan_latest.py @@ -0,0 +1,62 @@ +""" +Tests for scan_latest's handling of unsniffable .MLG files. + +Regression guard: a .MLG file the watcher can't extract a unit ID from must +be cached (as "no id") so it isn't re-sniffed and re-logged on every scan. +Previously the unsniffable result was never cached, so the +`[unsniffable-recent]` warning was re-emitted every scan cycle — ~200 files +x 288 scans/day flooded the log with hundreds of thousands of duplicate lines. +""" +import os +import time +import tempfile +import unittest + +import series3_watcher + + +class UnsniffableCaching(unittest.TestCase): + def test_unsniffable_recent_file_logged_once_across_scans(self): + with tempfile.TemporaryDirectory() as d: + # A .MLG file with no BE####/BA#### pattern → sniff returns None. + path = os.path.join(d, "junk001.MLG") + with open(path, "wb") as f: + f.write(b"\x00" * 512) + + calls = [] + cache = {} + recent_cutoff = time.time() - 86400.0 # file (mtime≈now) counts as recent + + for _ in range(3): + series3_watcher.scan_latest( + watch=d, + header_bytes=2048, + cache=cache, + recent_cutoff=recent_cutoff, + max_age_days=365, + logger=calls.append, + ) + + unsniff = [c for c in calls if "[unsniffable-recent]" in c] + self.assertEqual( + len(unsniff), 1, + "unsniffable file should log once across scans, got {}: {}".format( + len(unsniff), unsniff), + ) + + def test_sniffable_file_still_returned(self): + # Sanity: a file WITH a unit id is still detected (happy path intact). + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "evt001.MLG") + with open(path, "wb") as f: + f.write(b"hdr\x00BE12599\x00hdr") # contains a sniffable unit id + cache = {} + result = series3_watcher.scan_latest( + watch=d, header_bytes=2048, cache=cache, + recent_cutoff=time.time() - 86400.0, max_age_days=365, logger=None, + ) + self.assertIn("BE12599", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_series3_tray.py b/test_series3_tray.py new file mode 100644 index 0000000..c4f684c --- /dev/null +++ b/test_series3_tray.py @@ -0,0 +1,78 @@ +""" +Tests for series3_tray's thread lifecycle. + +Regression guard: saving the Settings dialog restarts the watcher thread via +``_restart_watcher``, which sets and replaces the shared ``stop_event``. The +auto-updater must key off its own lifetime event (``_app_stop``) so a watcher +restart never kills it; it should only stop when the app actually exits. +""" +import sys +import time +import threading +import unittest +from unittest import mock + +# Series3's tray pulls in Windows-only GUI deps at import time; stub them. +for _name in ("pystray", "PIL", "PIL.Image", "PIL.ImageDraw"): + sys.modules.setdefault(_name, mock.MagicMock()) + +import series3_tray # noqa: E402 + + +class TrayThreadLifecycle(unittest.TestCase): + def setUp(self): + # No-op watcher loop so restarting the watcher doesn't spin up the real + # Blastware scanner / network heartbeat. + patcher = mock.patch.object( + series3_tray.watcher, "run_watcher", lambda state, stop_event: None + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_app_stop_is_separate_lifetime_event(self): + app = series3_tray.WatcherTray() + self.assertIsInstance(app._app_stop, threading.Event) + self.assertIsNot(app._app_stop, app.stop_event) + self.assertFalse(app._app_stop.is_set()) + + def test_exit_signals_app_stop(self): + app = series3_tray.WatcherTray() + icon = mock.MagicMock() + app._exit(icon, None) + self.assertTrue(app._app_stop.is_set()) + icon.stop.assert_called_once() + + def test_restart_watcher_leaves_watcher_stop_unset(self): + app = series3_tray.WatcherTray() + app._restart_watcher() + self.assertFalse(app.stop_event.is_set()) + + def test_updater_survives_watcher_restart_then_stops_on_exit(self): + app = series3_tray.WatcherTray() + app._icon = None + with mock.patch.object(series3_tray, "check_for_update", return_value=(None, None)), \ + mock.patch.object(app, "_tray_status", return_value="ok"): + t = threading.Thread( + target=app._icon_updater, daemon=True, name="test-updater" + ) + t.start() + time.sleep(0.1) + self.assertTrue(t.is_alive(), "updater failed to start") + + # A settings save restarts the watcher — it must NOT kill the updater. + app._restart_watcher() + time.sleep(0.2) + self.assertTrue( + t.is_alive(), "updater thread died on watcher restart (the bug)" + ) + + # Real app exit DOES stop the updater. + app._app_stop.set() + t.join(timeout=2) + self.assertFalse( + t.is_alive(), "updater thread did not stop on app exit" + ) + + +if __name__ == "__main__": + unittest.main()