From 3303e228433d6665ebeb375bf9a967af0379b864 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 15:23:55 -0400 Subject: [PATCH 1/2] fix: update version to v1.4.2 and improve status reporting in tray feat: now sends watcher_status via payload to terra-view --- series3_tray.py | 75 ++++++++++++++++++++++++---------------------- series3_watcher.py | 46 ++++++++++------------------ 2 files changed, 55 insertions(+), 66 deletions(-) diff --git a/series3_tray.py b/series3_tray.py index 748125f..9f49daf 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — System Tray Launcher v1.4.1 +Series 3 Watcher — System Tray Launcher v1.4.2 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw series3_tray.py (no console window) @@ -277,46 +277,49 @@ class WatcherTray: status = self.state.get("status", "starting") last_err = self.state.get("last_error") last_scan = self.state.get("last_scan") + api_status = self.state.get("api_status", "disabled") + unit_count = len(self.state.get("units", [])) if status == "error": - return "Status: Error — {}".format(last_err or "unknown") + return "Error — {}".format(last_err or "unknown") if status == "starting": - return "Status: Starting..." + return "Starting..." + + # Scan age if last_scan is not None: age_secs = int((datetime.now() - last_scan).total_seconds()) - if age_secs < 60: - age_str = "{}s ago".format(age_secs) - else: - age_str = "{}m ago".format(age_secs // 60) - unit_count = len(self.state.get("units", [])) - return "Status: {} | {} unit(s) | scan {}".format( - status.upper(), unit_count, age_str - ) - return "Status: {}".format(status.upper()) - - def _build_units_submenu(self): - units = self.state.get("units", []) - if not units: - items = [pystray.MenuItem("No units detected", None, enabled=False)] + age_str = "{}s ago".format(age_secs) if age_secs < 60 else "{}m ago".format(age_secs // 60) else: - items = [] - for u in units: - label = "{uid} — {status} ({age:.1f}h ago)".format( - uid=u["uid"], - status=u["status"], - age=u["age_hours"], - ) - items.append(pystray.MenuItem(label, None, enabled=False)) - return pystray.Menu(*items) + age_str = "never" + + # API status label + if api_status == "ok": + api_str = "API OK" + elif api_status == "fail": + api_str = "API FAIL" + else: + api_str = "API off" + + return "Running — {} | {} unit(s) | scan {}".format(api_str, unit_count, age_str) + + def _tray_status(self): + """Return the icon status key based on watcher + API health.""" + status = self.state.get("status", "starting") + if status == "error": + return "error" + if status == "starting": + return "starting" + api_status = self.state.get("api_status", "disabled") + if api_status == "fail": + return "missing" # red — API failing + if api_status == "disabled": + return "pending" # amber — running but not reporting + return "ok" # green — running and API good def _build_menu(self): - # Use a callable for the status item so pystray re-evaluates it - # every time the menu is opened — keeps it in sync with the tooltip. return pystray.Menu( pystray.MenuItem(lambda item: self._status_text(), None, enabled=False), pystray.Menu.SEPARATOR, - pystray.MenuItem("Units", lambda item: self._build_units_submenu()), - pystray.Menu.SEPARATOR, pystray.MenuItem("Settings...", self._open_settings), pystray.MenuItem("Open Log Folder", self._open_logs), pystray.Menu.SEPARATOR, @@ -331,17 +334,17 @@ class WatcherTray: update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks) while not self.stop_event.is_set(): - status = self.state.get("status", "starting") + icon_status = self._tray_status() if self._icon is not None: - # Always rebuild menu every cycle so unit list and scan age stay fresh + # Always rebuild menu every cycle so status text stays fresh with self._menu_lock: self._icon.menu = self._build_menu() - if status != last_status: - self._icon.icon = make_icon(status) - self._icon.title = "Series 3 Watcher — {}".format(status.upper()) - last_status = status + if icon_status != last_status: + self._icon.icon = make_icon(icon_status) + self._icon.title = "Series 3 Watcher — {}".format(self._status_text()) + last_status = icon_status # Check if terra-view signalled an update via heartbeat response if self.state.get("update_available"): diff --git a/series3_watcher.py b/series3_watcher.py index b2f72ca..3358877 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — v1.4.1 +Series 3 Watcher — v1.4.2 Environment: - Python 3.8 (Windows 7 compatible) @@ -220,7 +220,7 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.4.1" +VERSION = "1.4.2" def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: @@ -398,45 +398,25 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: ) now_epoch = time.time() - # Build per-unit status list for the tray + # Log detected units to console and log file (info only, no status judgement) unit_list = [] - worst = "ok" # tracks worst status across all units - if latest: print("\nDetected Units (within last {} days):".format(MAX_EVENT_AGE_DAYS)) for uid in sorted(latest.keys()): info = latest[uid] age_hours = (now_epoch - info["mtime"]) / 3600.0 - if age_hours > MISSING_HOURS: - status, col = "missing", C_MIS - elif age_hours > OK_HOURS: - status, col = "pending", C_PEN - else: - status, col = "ok", C_OK - - # escalate worst status - if status == "missing": - worst = "missing" - elif status == "pending" and worst != "missing": - worst = "pending" - unit_list.append({ "uid": uid, - "status": status, "age_hours": age_hours, "last": fmt_last(info["mtime"]), "fname": info["fname"], }) - line = ( - "{col}{uid:<8} {status:<8} Age: {age:<7} Last: {last} (File: {fname}){rst}".format( - col=col, + "{uid:<8} Age: {age:<7} Last: {last} (File: {fname})".format( uid=uid, - status=status.capitalize(), age=fmt_age(now_epoch, info["mtime"]), last=fmt_last(info["mtime"]), fname=info["fname"], - rst=C_RST, ) ) print(line) @@ -448,10 +428,9 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: ENABLE_LOGGING, "[info] no recent MLG activity within {} days".format(MAX_EVENT_AGE_DAYS), ) - worst = "missing" - # Update shared state for tray - state["status"] = worst + # Update shared state for tray — status reflects watcher health, not unit ages + state["status"] = "running" state["units"] = unit_list state["last_scan"] = datetime.now() state["last_error"] = None @@ -463,12 +442,19 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: if now_ts - last_api_ts >= interval: hb_payload = build_sfm_payload(latest, cfg) hb_payload["version"] = VERSION + hb_payload["watcher_status"] = state.get("status", "unknown") hb_payload["log_tail"] = _read_log_tail(cfg.get("LOG_FILE", ""), 25) response = send_api_payload(hb_payload, cfg.get("API_URL", "")) last_api_ts = now_ts - # Surface update signal to tray - if response and response.get("update_available"): - state["update_available"] = True + if response is not None: + state["api_status"] = "ok" + state["last_api"] = datetime.now() + if response.get("update_available"): + state["update_available"] = True + else: + state["api_status"] = "fail" + else: + state["api_status"] = "disabled" except Exception as e: err = "[loop-error] {}".format(e) From d2a8c2d928afa9eb3819987e53260f8d8312c9dd Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 16:02:24 -0400 Subject: [PATCH 2/2] Update to v1.4.2 Feat: tray icon now shows API/watcher health rather than unit ages. unit submenu removed, now handled by recieving software. Chore: remove old unneeded code from deprecated features (console colorization, Missing/pending age limits) --- CHANGELOG.md | 9 +++++++++ README.md | 5 ++--- config-template.ini | 3 --- installer.iss | 2 +- series3_watcher.py | 19 ++++--------------- settings_dialog.py | 6 +----- 6 files changed, 17 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d1dbf..e7cf50f 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 --- +## [1.4.2] - 2026-03-17 + +### Changed +- Tray icon color now reflects watcher + API health rather than unit ages — green=API OK, amber=API disabled, red=API failing, purple=watcher error. +- Status menu text updated to show `Running — API OK | N unit(s) | scan Xm ago`. +- Units submenu removed from tray — status tracking for individual units is handled by terra-view, not the watcher. +- Unit list still logged to console and log file for debugging, but no OK/Pending/Missing judgement applied. +- `watcher_status` field added to heartbeat payload so terra-view receives accurate watcher health data. + ## [1.4.1] - 2026-03-17 ### Fixed diff --git a/README.md b/README.md index 824b220..5cf606e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.4.1 +# Series 3 Watcher v1.4.2 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. @@ -82,7 +82,6 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here |-----|-------------| | `ENABLE_LOGGING` | `true` / `false` | | `LOG_RETENTION_DAYS` | Auto-clear log after this many days (default `30`) | -| `COLORIZE` | ANSI colours in console — leave `false` on Win7 | --- @@ -117,7 +116,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.4.1**. +Follows **Semantic Versioning**. Current release: **v1.4.2**. See `CHANGELOG.md` for full history. --- diff --git a/config-template.ini b/config-template.ini index 5c5376c..86c5677 100644 --- a/config-template.ini +++ b/config-template.ini @@ -22,9 +22,6 @@ ENABLE_LOGGING = True LOG_FILE = C:\Users\%USERNAME%\AppData\Local\Series3Watcher\agent_logs\series3_watcher.log LOG_RETENTION_DAYS = 30 -# Console colors - (Doesn't work on windows 7) -COLORIZE = FALSE - # .MLG parsing MLG_HEADER_BYTES = 2048 ; used for unit-id extraction diff --git a/installer.iss b/installer.iss index cbb508b..9cf5410 100644 --- a/installer.iss +++ b/installer.iss @@ -3,7 +3,7 @@ [Setup] AppName=Series 3 Watcher -AppVersion=1.4.1 +AppVersion=1.4.2 AppPublisher=Terra-Mechanics Inc. DefaultDirName={pf}\Series3Watcher DefaultGroupName=Series 3 Watcher diff --git a/series3_watcher.py b/series3_watcher.py index 3358877..543eb89 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -68,7 +68,6 @@ def load_config(path: str) -> Dict[str, Any]: "Series3Watcher", "agent_logs", "series3_watcher.log" )), "LOG_RETENTION_DAYS": get_int("LOG_RETENTION_DAYS", 30), - "COLORIZE": get_bool("COLORIZE", False), # Win7 default off "MLG_HEADER_BYTES": max(256, min(get_int("MLG_HEADER_BYTES", 2048), 65536)), "RECENT_WARN_DAYS": get_int("RECENT_WARN_DAYS", 30), "MAX_EVENT_AGE_DAYS": get_int("MAX_EVENT_AGE_DAYS", 365), @@ -82,10 +81,6 @@ def load_config(path: str) -> Dict[str, Any]: } -# --------------- ANSI helpers --------------- -def ansi(enabled: bool, code: str) -> str: - return code if enabled else "" - # --------------- Logging -------------------- def log_message(path: str, enabled: bool, msg: str) -> None: @@ -310,9 +305,11 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: Main watcher loop. Runs in a background thread when launched from the tray. state dict is written on every scan cycle: - state["status"] — "ok" | "pending" | "missing" | "error" | "starting" - state["units"] — list of dicts: {uid, status, age_hours, last, fname} + state["status"] — "running" | "error" | "starting" + state["api_status"] — "ok" | "fail" | "disabled" + state["units"] — list of dicts: {uid, age_hours, last, fname} state["last_scan"] — datetime of last successful scan (or None) + state["last_api"] — datetime of last successful API POST (or None) state["last_error"] — last error string (or None) state["log_dir"] — directory containing the log file state["cfg"] — loaded config dict @@ -345,21 +342,13 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: WATCH_PATH = cfg["WATCH_PATH"] SCAN_INTERVAL = int(cfg["SCAN_INTERVAL"]) - OK_HOURS = float(cfg["OK_HOURS"]) - MISSING_HOURS = float(cfg["MISSING_HOURS"]) ENABLE_LOGGING = bool(cfg["ENABLE_LOGGING"]) LOG_FILE = cfg["LOG_FILE"] LOG_RETENTION_DAYS = int(cfg["LOG_RETENTION_DAYS"]) - COLORIZE = bool(cfg["COLORIZE"]) MLG_HEADER_BYTES = int(cfg["MLG_HEADER_BYTES"]) RECENT_WARN_DAYS = int(cfg["RECENT_WARN_DAYS"]) MAX_EVENT_AGE_DAYS = int(cfg["MAX_EVENT_AGE_DAYS"]) - C_OK = ansi(COLORIZE, "\033[92m") - C_PEN = ansi(COLORIZE, "\033[93m") - C_MIS = ansi(COLORIZE, "\033[91m") - C_RST = ansi(COLORIZE, "\033[0m") - print( "[CFG] WATCH_PATH={} SCAN_INTERVAL={}s MAX_EVENT_AGE_DAYS={} API_ENABLED={}".format( WATCH_PATH, SCAN_INTERVAL, MAX_EVENT_AGE_DAYS, bool(cfg.get("API_ENABLED", False)) diff --git a/settings_dialog.py b/settings_dialog.py index a5414d4..8232f9f 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — Settings Dialog v1.4.0 +Series 3 Watcher — Settings Dialog v1.4.2 Provides a Tkinter settings dialog that doubles as a first-run wizard. @@ -39,7 +39,6 @@ DEFAULTS = { "Series3Watcher", "agent_logs", "series3_watcher.log" ), "LOG_RETENTION_DAYS": "30", - "COLORIZE": "false", } @@ -233,7 +232,6 @@ class SettingsDialog: # Logging self.var_enable_logging = tk.BooleanVar(value=v["ENABLE_LOGGING"].lower() in ("1","true","yes","on")) self.var_log_retention_days = tk.StringVar(value=v["LOG_RETENTION_DAYS"]) - self.var_colorize = tk.BooleanVar(value=v["COLORIZE"].lower() in ("1","true","yes","on")) # --- UI construction --- @@ -399,7 +397,6 @@ class SettingsDialog: f = self._tab_frame(nb, "Logging") _add_label_check(f, 0, "Enable Logging", self.var_enable_logging) _add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365) - _add_label_check(f, 2, "Colorize console output", self.var_colorize) # --- Validation helpers --- @@ -470,7 +467,6 @@ class SettingsDialog: "ENABLE_LOGGING": "true" if self.var_enable_logging.get() else "false", "LOG_FILE": self.var_log_file.get().strip(), "LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]), - "COLORIZE": "true" if self.var_colorize.get() else "false", } try: