Compare commits
2 Commits
fb1edfbad4
..
v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4ea822fd | |||
| aa341c7342 |
@@ -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
|
## [6-25-26] — v1.6.0
|
||||||
|
|
||||||
Optional dual-send mirror so a standby server stays a live replica.
|
Optional dual-send mirror so a standby server stays a live replica.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Versioning
|
||||||
|
|
||||||
Follows **Semantic Versioning**. Current release: **v1.6.0**.
|
Follows **Semantic Versioning**. Current release: **v1.6.1**.
|
||||||
See `CHANGELOG.md` for full history.
|
See `CHANGELOG.md` for full history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
AppName=Series 3 Watcher
|
AppName=Series 3 Watcher
|
||||||
AppVersion=1.6.0
|
AppVersion=1.6.1
|
||||||
AppPublisher=Terra-Mechanics Inc.
|
AppPublisher=Terra-Mechanics Inc.
|
||||||
DefaultDirName={pf}\Series3Watcher
|
DefaultDirName={pf}\Series3Watcher
|
||||||
DefaultGroupName=Series 3 Watcher
|
DefaultGroupName=Series 3 Watcher
|
||||||
|
|||||||
+7
-4
@@ -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)
|
Requires: pystray, Pillow, tkinter (stdlib)
|
||||||
|
|
||||||
Run with: pythonw series3_tray.py (no console window)
|
Run with: pythonw series3_tray.py (no console window)
|
||||||
@@ -335,7 +335,8 @@ def _show_cancel_message():
|
|||||||
class WatcherTray:
|
class WatcherTray:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.state = {}
|
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._watcher_thread = None
|
||||||
self._icon = None
|
self._icon = None
|
||||||
# Lock guards _rebuild_menu calls from the updater thread
|
# Lock guards _rebuild_menu calls from the updater thread
|
||||||
@@ -394,6 +395,7 @@ class WatcherTray:
|
|||||||
subprocess.Popen(["explorer", HERE])
|
subprocess.Popen(["explorer", HERE])
|
||||||
|
|
||||||
def _exit(self, icon, item):
|
def _exit(self, icon, item):
|
||||||
|
self._app_stop.set()
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
icon.stop()
|
icon.stop()
|
||||||
|
|
||||||
@@ -459,7 +461,7 @@ class WatcherTray:
|
|||||||
last_status = None
|
last_status = None
|
||||||
update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks)
|
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()
|
icon_status = self._tray_status()
|
||||||
|
|
||||||
if self._icon is not None:
|
if self._icon is not None:
|
||||||
@@ -487,7 +489,7 @@ class WatcherTray:
|
|||||||
self._do_update(url)
|
self._do_update(url)
|
||||||
return # exit loop; swap bat will relaunch
|
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):
|
def _do_update(self, download_url=None):
|
||||||
"""Notify tray icon then apply update. If url is None, fetch it first."""
|
"""Notify tray icon then apply update. If url is None, fetch it first."""
|
||||||
@@ -502,6 +504,7 @@ class WatcherTray:
|
|||||||
|
|
||||||
success = apply_update(download_url)
|
success = apply_update(download_url)
|
||||||
if success:
|
if success:
|
||||||
|
self._app_stop.set()
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
if self._icon is not None:
|
if self._icon is not None:
|
||||||
self._icon.stop()
|
self._icon.stop()
|
||||||
|
|||||||
+9
-6
@@ -237,13 +237,16 @@ def scan_latest(
|
|||||||
uid = cached[1]
|
uid = cached[1]
|
||||||
else:
|
else:
|
||||||
uid = sniff_unit_from_mlg(fpath, header_bytes)
|
uid = sniff_unit_from_mlg(fpath, header_bytes)
|
||||||
if not uid:
|
# Cache the result either way — including unsniffable (uid=None) —
|
||||||
# If unsniffable but very recent, log for later inspection
|
# so the same file isn't re-sniffed and re-logged on every scan.
|
||||||
if (recent_cutoff is not None) and (mtime >= recent_cutoff):
|
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:
|
if logger:
|
||||||
logger("[unsniffable-recent] {}".format(fpath))
|
logger("[unsniffable-recent] {}".format(fpath))
|
||||||
continue # skip file if no unit ID found in header
|
|
||||||
cache[fpath] = (mtime, uid)
|
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"]):
|
if (uid not in latest) or (mtime > latest[uid]["mtime"]):
|
||||||
latest[uid] = {"mtime": mtime, "fname": e.name, "path": fpath}
|
latest[uid] = {"mtime": mtime, "fname": e.name, "path": fpath}
|
||||||
@@ -253,7 +256,7 @@ def scan_latest(
|
|||||||
|
|
||||||
|
|
||||||
# --- API heartbeat / SFM telemetry helpers ---
|
# --- 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]:
|
def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]:
|
||||||
|
|||||||
+1
-1
@@ -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.
|
Provides a Tkinter settings dialog that doubles as a first-run wizard.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user