From 9b20d93f4cf90f575a7281925c1cb27e9bbfef9d Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 5 Mar 2026 23:10:44 -0500 Subject: [PATCH 01/12] chore: cleanup gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6e3e0a3..f5121db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# -------------------------- +# s3-agent files +# -------------------------- config.ini # ------------------------- -- 2.49.1 From 00956c022ab0524538d0e60d9f5e6f794981b704 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Thu, 12 Mar 2026 19:14:30 -0400 Subject: [PATCH 02/12] Rename to series 3 watcher --- CHANGELOG.md | 13 +++++++++++-- README.md | 6 +++--- README_DL2.md | 2 +- config-template.ini | 4 ++-- series3_agent.py => series3_watcher.py | 8 ++++---- 5 files changed, 21 insertions(+), 12 deletions(-) rename series3_agent.py => series3_watcher.py (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b93df6a..e1328fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,20 @@ # Changelog -All notable changes to **Series3 Agent** will be documented in this file. +All notable changes to **Series 3 Watcher** will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [1.3.0] - 2026-03-12 + +### Changed +- Renamed program to "series3-watcher" and main script to `series3_watcher.py` — better reflects what it does (watches for activity) rather than implying active data emission. +- Default `SOURCE_TYPE` updated to `series3_watcher`. +- Default log filename updated to `series3_watcher.log`. + +--- + ## [1.2.1] - 2026-03-03 ### Changed diff --git a/README.md b/README.md index 69b6a90..d92c78e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series3 Ingest Agent v1.2 +# Series 3 Watcher v1.3 A lightweight Python script that monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. @@ -30,7 +30,7 @@ Install dependencies with: Run the agent from the folder containing the script: -`python series3_agent.py` +`python series3_watcher.py` The script will: @@ -74,7 +74,7 @@ Git ignores all log files but keeps the folder itself. This repo follows **Semantic Versioning (SemVer)**. -Current release: **v1.2.1** — renamed to series3 ingest agent. +Current release: **v1.3.0** — renamed to series3-watcher. See `CHANGELOG.md` for details. --- diff --git a/README_DL2.md b/README_DL2.md index 43418e4..0f97dff 100644 --- a/README_DL2.md +++ b/README_DL2.md @@ -1,4 +1,4 @@ -# Series 3 Ingest Agent — v1_0(py38-safe) for DL2 +# Series 3 Watcher — v1_0(py38-safe) for DL2 **Target**: Windows 7 + Python 3.8.10 **Baseline**: v5_4 (no logic changes) diff --git a/config-template.ini b/config-template.ini index 849a4cd..dbcd591 100644 --- a/config-template.ini +++ b/config-template.ini @@ -5,7 +5,7 @@ API_ENABLED = true API_URL = API_INTERVAL_SECONDS = 300 SOURCE_ID = #computer that is running agent. -SOURCE_TYPE = series3_agent +SOURCE_TYPE = series3_watcher # Paths SERIES3_PATH = C:\Blastware 10\Event\autocall home @@ -19,7 +19,7 @@ MISSING_HOURS = 24 # Logging ENABLE_LOGGING = True -LOG_FILE = C:\SeismoEmitter\agent_logs\series3_agent.log +LOG_FILE = C:\SeismoEmitter\agent_logs\series3_watcher.log LOG_RETENTION_DAYS = 30 # Console colors - (Doesn't work on windows 7) diff --git a/series3_agent.py b/series3_watcher.py similarity index 98% rename from series3_agent.py rename to series3_watcher.py index 080544e..d5d7000 100644 --- a/series3_agent.py +++ b/series3_watcher.py @@ -1,5 +1,5 @@ """ -Series 3 Ingest Agent — v1.2.1 +Series 3 Watcher — v1.3.0 Environment: - Python 3.8 (Windows 7 compatible) @@ -63,7 +63,7 @@ def load_config(path: str) -> Dict[str, Any]: "OK_HOURS": float(get_int("OK_HOURS", 12)), "MISSING_HOURS": float(get_int("MISSING_HOURS", 24)), "ENABLE_LOGGING": get_bool("ENABLE_LOGGING", True), - "LOG_FILE": get_str("LOG_FILE", r"C:\SeismoEmitter\agent_logs\series3_agent.log"), + "LOG_FILE": get_str("LOG_FILE", r"C:\SeismoEmitter\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)), @@ -75,7 +75,7 @@ def load_config(path: str) -> Dict[str, Any]: "API_URL": get_str("API_URL", ""), "API_INTERVAL_SECONDS": get_int("API_INTERVAL_SECONDS", 300), "SOURCE_ID": get_str("SOURCE_ID", gethostname()), - "SOURCE_TYPE": get_str("SOURCE_TYPE", "series3_ingest_agent"), + "SOURCE_TYPE": get_str("SOURCE_TYPE", "series3_watcher"), } @@ -239,7 +239,7 @@ def build_sfm_payload(units_dict: Dict[str, Dict[str, Any]], cfg: Dict[str, Any] payload = { "source_id": cfg.get("SOURCE_ID", gethostname()), - "source_type": cfg.get("SOURCE_TYPE", "series3_ingest_agent"), + "source_type": cfg.get("SOURCE_TYPE", "series3_watcher"), "timestamp": now_iso, "units": [], } -- 2.49.1 From 0807e09047670262e1446da712374653b0a25b77 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 13 Mar 2026 17:40:28 -0400 Subject: [PATCH 03/12] feat: windows installer with remote updates and remote management added. --- CHANGELOG.md | 13 ++ build.bat | 16 ++ installer.iss | 37 ++++ requirements.txt | 6 +- series3_tray.py | 407 ++++++++++++++++++++++++++++++++++++++++ series3_watcher.py | 127 ++++++++++--- settings_dialog.py | 459 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1037 insertions(+), 28 deletions(-) create mode 100644 build.bat create mode 100644 installer.iss create mode 100644 series3_tray.py create mode 100644 settings_dialog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1328fc..64b595a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.4.0] - 2026-03-12 + +### Added +- `series3_tray.py` — system tray launcher using `pystray` + `Pillow`. Color-coded icon (green=OK, amber=Pending, red=Missing, purple=Error, grey=Starting). Right-click menu shows live status, unit count, last scan age, Open Log Folder, and Exit. +- `run_watcher(state, stop_event)` in `series3_watcher.py` for background thread use by the tray. Shared `state` dict updated on every scan cycle with status, unit list, last scan time, and last error. +- Interruptible sleep in watcher loop — tray exit is immediate, no waiting out the full scan interval. + +### Changed +- `main()` now calls `run_watcher()` — standalone behavior unchanged. +- `requirements.txt` updated to document tray dependencies (`pystray`, `Pillow`); watcher itself remains stdlib-only. + +--- + ## [1.3.0] - 2026-03-12 ### Changed diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..4fd4dee --- /dev/null +++ b/build.bat @@ -0,0 +1,16 @@ +@echo off +echo Building series3-watcher.exe... +pip install pyinstaller pystray Pillow + +REM Check whether icon.ico exists alongside this script. +REM If it does, include it; otherwise build without a custom icon. +if exist "%~dp0icon.ico" ( + pyinstaller --onefile --windowed --name series3-watcher --icon="%~dp0icon.ico" series3_tray.py +) else ( + echo [INFO] icon.ico not found -- building without custom icon. + pyinstaller --onefile --windowed --name series3-watcher series3_tray.py +) + +echo. +echo Done. Check dist\series3-watcher.exe +pause diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..2b9e5be --- /dev/null +++ b/installer.iss @@ -0,0 +1,37 @@ +; Inno Setup script for Series 3 Watcher +; Run through Inno Setup Compiler after building dist\series3-watcher.exe + +[Setup] +AppName=Series 3 Watcher +AppVersion=1.4.0 +AppPublisher=Terra-Mechanics Inc. +DefaultDirName={pf}\Series3Watcher +DefaultGroupName=Series 3 Watcher +OutputBaseFilename=series3-watcher-setup +Compression=lzma +SolidCompression=yes +; Require admin rights so we can write to Program Files +PrivilegesRequired=admin + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:"; Flags: unchecked + +[Files] +; Main executable — built by build.bat / PyInstaller +Source: "dist\series3-watcher.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; Start Menu shortcut +Name: "{group}\Series 3 Watcher"; Filename: "{app}\series3-watcher.exe" +; Start Menu uninstall shortcut +Name: "{group}\Uninstall Series 3 Watcher"; Filename: "{uninstallexe}" +; Desktop shortcut (optional — controlled by [Tasks] above) +Name: "{commondesktop}\Series 3 Watcher"; Filename: "{app}\series3-watcher.exe"; Tasks: desktopicon +; Startup folder shortcut so the tray app launches on login +Name: "{userstartup}\Series 3 Watcher"; Filename: "{app}\series3-watcher.exe" + +[Run] +; Offer to launch the app after install (unchecked by default) +Filename: "{app}\series3-watcher.exe"; \ + Description: "Launch Series 3 Watcher"; \ + Flags: nowait postinstall skipifsilent unchecked diff --git a/requirements.txt b/requirements.txt index e442582..c9d51d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ -# Python 3.8.10 standard library only (no external packages required). +# series3_watcher.py — stdlib only, no external packages required. + +# series3_tray.py — required for system tray mode: +pystray>=0.19.5 +Pillow>=9.0.0 diff --git a/series3_tray.py b/series3_tray.py new file mode 100644 index 0000000..c128487 --- /dev/null +++ b/series3_tray.py @@ -0,0 +1,407 @@ +""" +Series 3 Watcher — System Tray Launcher v1.4.0 +Requires: pystray, Pillow, tkinter (stdlib) + +Run with: pythonw series3_tray.py (no console window) + or: python series3_tray.py (with console, for debugging) + +Put a shortcut to this in shell:startup for auto-start on login. + +Python 3.8 compatible — no walrus operators, no f-string = specifier, +no match statements, no 3.9+ syntax. +""" + +import os +import sys +import subprocess +import tempfile +import threading +import urllib.request +import urllib.error +from datetime import datetime + +import pystray +from PIL import Image, ImageDraw + +import series3_watcher as watcher + + +# --------------- Auto-updater --------------- + +GITEA_BASE = "https://gitea.serversdown.net" +GITEA_USER = "serversdown" +GITEA_REPO = "series3-watcher" +GITEA_API_URL = "{}/api/v1/repos/{}/{}/releases?limit=1&page=1".format( + GITEA_BASE, GITEA_USER, GITEA_REPO +) + +# Populated from watcher version string at startup +_CURRENT_VERSION = getattr(watcher, "VERSION", "0.0.0") + + +def _version_tuple(v): + """Convert '1.4.0' -> (1, 4, 0) for comparison. Non-numeric parts -> 0.""" + parts = [] + for p in str(v).lstrip("v").split(".")[:3]: + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + while len(parts) < 3: + parts.append(0) + return tuple(parts) + + +def check_for_update(): + """ + Query Gitea for the latest release. + Returns (tag, download_url) if an update is available, else (None, None). + """ + import json as _json + try: + req = urllib.request.Request( + GITEA_API_URL, + headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)}, + ) + with urllib.request.urlopen(req, timeout=8) as resp: + releases = _json.loads(resp.read().decode("utf-8")) + if not releases: + return None, None + latest = releases[0] + tag = latest.get("tag_name", "") + if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): + return None, None + # Find the .exe asset + assets = latest.get("assets", []) + for asset in assets: + name = asset.get("name", "") + if name.lower().endswith(".exe"): + return tag, asset.get("browser_download_url") + return tag, None + except Exception: + return None, None + + +def apply_update(download_url): + """ + Download new .exe to a temp file, write a swap .bat, launch it, exit. + The bat waits for us to exit, then swaps the files and relaunches. + """ + exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0]) + + try: + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="s3w_update_") + os.close(tmp_fd) + + req = urllib.request.Request( + download_url, + headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)}, + ) + with urllib.request.urlopen(req, timeout=60) as resp: + with open(tmp_path, "wb") as f: + f.write(resp.read()) + + bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="s3w_swap_") + os.close(bat_fd) + + bat_content = ( + "@echo off\r\n" + "ping 127.0.0.1 -n 4 > nul\r\n" + "copy /Y \"{new}\" \"{exe}\"\r\n" + "start \"\" \"{exe}\"\r\n" + "del \"%~f0\"\r\n" + ).format(new=tmp_path, exe=exe_path) + + with open(bat_path, "w") as f: + f.write(bat_content) + + subprocess.Popen( + ["cmd", "/C", bat_path], + creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, + ) + return True + except Exception: + return False + + +# --------------- Paths --------------- + +HERE = os.path.dirname(os.path.abspath(__file__)) +CONFIG_PATH = os.path.join(HERE, "config.ini") + + +# --------------- Icon drawing --------------- + +COLORS = { + "ok": (60, 200, 80), # green + "pending": (230, 180, 0), # amber + "missing": (210, 40, 40), # red + "error": (160, 40, 200), # purple + "starting": (120, 120, 120), # grey +} + +ICON_SIZE = 64 + + +def make_icon(status): + """Draw a solid filled circle on a transparent background.""" + color = COLORS.get(status, COLORS["starting"]) + img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + margin = 6 + draw.ellipse( + [margin, margin, ICON_SIZE - margin, ICON_SIZE - margin], + fill=color, + ) + return img + + +# --------------- First-run check --------------- + +def ensure_config(): + """ + If config.ini is missing, launch the first-run wizard. + Returns True if config is ready, False if user cancelled. + """ + if os.path.exists(CONFIG_PATH): + return True + + # Import here to avoid pulling in tkinter unless needed + from settings_dialog import show_dialog + saved = show_dialog(CONFIG_PATH, wizard=True) + if not saved: + _show_cancel_message() + return False + return True + + +def _show_cancel_message(): + """Show a plain messagebox telling the user the app cannot start.""" + try: + import tkinter as tk + from tkinter import messagebox + root = tk.Tk() + root.withdraw() + messagebox.showwarning( + "Series 3 Watcher", + "No configuration was saved.\nThe application will now exit.", + ) + root.destroy() + except Exception: + pass + + +# --------------- Tray app --------------- + +class WatcherTray: + def __init__(self): + self.state = {} + self.stop_event = threading.Event() + self._watcher_thread = None + self._icon = None + # Lock guards _rebuild_menu calls from the updater thread + self._menu_lock = threading.Lock() + + # --- Watcher thread management --- + + def _start_watcher(self): + self.stop_event.clear() + self._watcher_thread = threading.Thread( + target=watcher.run_watcher, + args=(self.state, self.stop_event), + daemon=True, + name="watcher", + ) + self._watcher_thread.start() + + def _stop_watcher(self): + self.stop_event.set() + if self._watcher_thread is not None: + self._watcher_thread.join(timeout=10) + self._watcher_thread = None + + def _restart_watcher(self): + """Stop any running watcher and start a fresh one.""" + self._stop_watcher() + self.stop_event = threading.Event() + self.state["status"] = "starting" + self.state["units"] = [] + self.state["last_scan"] = None + self.state["last_error"] = None + self._start_watcher() + + # --- Menu item callbacks --- + + def _open_settings(self, icon, item): + """Open the settings dialog. On save, restart watcher thread.""" + from settings_dialog import show_dialog + saved = show_dialog(CONFIG_PATH, wizard=False) + if saved: + self._restart_watcher() + # Rebuild menu so status label refreshes + if self._icon is not None: + with self._menu_lock: + self._icon.menu = self._build_menu() + + def _open_logs(self, icon, item): + log_dir = self.state.get("log_dir") + if not log_dir: + log_dir = HERE + if os.path.exists(log_dir): + subprocess.Popen(["explorer", log_dir]) + else: + parent = os.path.dirname(log_dir) + if os.path.exists(parent): + subprocess.Popen(["explorer", parent]) + else: + subprocess.Popen(["explorer", HERE]) + + def _exit(self, icon, item): + self.stop_event.set() + icon.stop() + + # --- Dynamic menu text helpers --- + + def _status_text(self): + status = self.state.get("status", "starting") + last_err = self.state.get("last_error") + last_scan = self.state.get("last_scan") + + if status == "error": + return "Status: Error — {}".format(last_err or "unknown") + if status == "starting": + return "Status: Starting..." + 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)] + 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) + + def _build_menu(self): + # Capture current text/submenu at build time; pystray will call + # callables each render, but static strings are fine for infrequent + # menu rebuilds. We use callables for the dynamic items so that the + # text shown on hover/open is current. + status_text = self._status_text() + units_submenu = self._build_units_submenu() + + return pystray.Menu( + pystray.MenuItem(status_text, None, enabled=False), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Units", units_submenu), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Settings...", self._open_settings), + pystray.MenuItem("Open Log Folder", self._open_logs), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Exit", self._exit), + ) + + # --- Icon / menu update loop --- + + def _icon_updater(self): + """Periodically refresh the tray icon color and menu to match watcher state.""" + last_status = None + 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") + + if self._icon is not None: + # Always rebuild menu every cycle so unit list and scan age stay 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 + + # Check if terra-view signalled an update via heartbeat response + if self.state.get("update_available"): + self.state["update_available"] = False + self._do_update() + return # exit loop; swap bat will relaunch + + # Periodic Gitea update check (every ~5 min) + update_check_counter += 1 + if update_check_counter >= 30: + update_check_counter = 0 + tag, url = check_for_update() + if tag and url: + self._do_update(url) + return # exit loop; swap bat will relaunch + + self.stop_event.wait(timeout=10) + + def _do_update(self, download_url=None): + """Notify tray icon then apply update. If url is None, fetch it first.""" + if download_url is None: + _, download_url = check_for_update() + if not download_url: + return + + if self._icon is not None: + self._icon.title = "Series 3 Watcher — Updating..." + self._icon.icon = make_icon("starting") + + success = apply_update(download_url) + if success: + self.stop_event.set() + if self._icon is not None: + self._icon.stop() + # If update failed, just keep running silently + + # --- Entry point --- + + def run(self): + self._start_watcher() + + icon_img = make_icon("starting") + self._icon = pystray.Icon( + name="series3_watcher", + icon=icon_img, + title="Series 3 Watcher — Starting...", + menu=self._build_menu(), + ) + + updater = threading.Thread( + target=self._icon_updater, daemon=True, name="icon-updater" + ) + updater.start() + + self._icon.run() + + +# --------------- Entry point --------------- + +def main(): + if not ensure_config(): + sys.exit(0) + + app = WatcherTray() + app.run() + + +if __name__ == "__main__": + main() diff --git a/series3_watcher.py b/series3_watcher.py index d5d7000..e1d76f5 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — v1.3.0 +Series 3 Watcher — v1.4.0 Environment: - Python 3.8 (Windows 7 compatible) @@ -12,15 +12,14 @@ Key Features: - Safe .MLG header sniff for unit IDs (BE#### / BA####) - Standardized SFM Telemetry JSON payload (source-agnostic) - Periodic HTTP heartbeat POST to SFM backend -- NEW in v1.2.0: - - No local roster / CSV dependency - - Only scans .MLG files newer than MAX_EVENT_AGE_DAYS +- Tray-friendly: run_watcher(state, stop_event) for background thread use """ import os import re import time import json +import threading import configparser import urllib.request import urllib.error @@ -95,7 +94,7 @@ def log_message(path: str, enabled: bool, msg: str) -> None: with open(path, "a", encoding="utf-8") as f: f.write("{} {}\n".format(datetime.now(timezone.utc).isoformat(), msg)) except Exception: - # Logging must never crash the agent + # Logging must never crash the watcher pass @@ -205,7 +204,7 @@ def scan_latest( # If unsniffable but very recent, log for later inspection if (recent_cutoff is not None) and (mtime >= recent_cutoff): if logger: - logger(f"[unsniffable-recent] {fpath}") + logger("[unsniffable-recent] {}".format(fpath)) continue # skip file if no unit ID found in header cache[fpath] = (mtime, uid) @@ -217,16 +216,25 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -def send_api_payload(payload: dict, api_url: str) -> None: +VERSION = "1.4.0" + + +def send_api_payload(payload: dict, api_url: str) -> Optional[dict]: + """POST payload to API. Returns parsed JSON response dict, or None on failure.""" if not api_url: - return + return None data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(api_url, data=data, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=5) as res: - print(f"[API] POST success: {res.status}") + print("[API] POST success: {}".format(res.status)) + try: + return json.loads(res.read().decode("utf-8")) + except Exception: + return None except urllib.error.URLError as e: - print(f"[API] POST failed: {e}") + print("[API] POST failed: {}".format(e)) + return None def build_sfm_payload(units_dict: Dict[str, Dict[str, Any]], cfg: Dict[str, Any]) -> dict: @@ -280,10 +288,38 @@ def build_sfm_payload(units_dict: Dict[str, Dict[str, Any]], cfg: Dict[str, Any] return payload -# --------------- Main loop ------------------ -def main() -> None: +# --------------- Watcher loop (tray-friendly) ---------------- +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["last_scan"] — datetime of last successful scan (or None) + state["last_error"] — last error string (or None) + state["log_dir"] — directory containing the log file + state["cfg"] — loaded config dict + """ here = os.path.dirname(__file__) or "." - cfg = load_config(os.path.join(here, "config.ini")) + config_path = os.path.join(here, "config.ini") + + state["status"] = "starting" + state["units"] = [] + state["last_scan"] = None + state["last_error"] = None + state["log_dir"] = None + state["cfg"] = {} + + try: + cfg = load_config(config_path) + except Exception as e: + state["status"] = "error" + state["last_error"] = "Config load failed: {}".format(e) + return + + state["cfg"] = cfg + state["log_dir"] = os.path.dirname(cfg["LOG_FILE"]) or here WATCH_PATH = cfg["WATCH_PATH"] SCAN_INTERVAL = int(cfg["SCAN_INTERVAL"]) @@ -315,12 +351,10 @@ def main() -> None: ), ) - # cache for scanning sniff_cache: Dict[str, Tuple[float, str]] = {} - last_api_ts: float = 0.0 - while True: + while not stop_event.is_set(): try: now_local = datetime.now().isoformat() now_utc = datetime.now(timezone.utc).isoformat() @@ -342,24 +376,41 @@ def main() -> None: ) now_epoch = time.time() - # Detected units summary (no roster dependency) + # Build per-unit status list for the tray + 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 + status, col = "missing", C_MIS elif age_hours > OK_HOURS: - status, col = "Pending", C_PEN + status, col = "pending", C_PEN else: - status, col = "OK", C_OK + 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=uid, - status=status, + status=status.capitalize(), age=fmt_age(now_epoch, info["mtime"]), last=fmt_last(info["mtime"]), fname=info["fname"], @@ -375,25 +426,47 @@ def main() -> 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 + state["units"] = unit_list + state["last_scan"] = datetime.now() + state["last_error"] = None # ---- API heartbeat to SFM ---- if cfg.get("API_ENABLED", False): now_ts = time.time() interval = int(cfg.get("API_INTERVAL_SECONDS", 300)) if now_ts - last_api_ts >= interval: - payload = build_sfm_payload(latest, cfg) - send_api_payload(payload, cfg.get("API_URL", "")) + hb_payload = build_sfm_payload(latest, cfg) + hb_payload["version"] = VERSION + 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 - except KeyboardInterrupt: - print("\nStopping...") - break except Exception as e: err = "[loop-error] {}".format(e) print(err) log_message(LOG_FILE, ENABLE_LOGGING, err) + state["status"] = "error" + state["last_error"] = str(e) - time.sleep(SCAN_INTERVAL) + # Interruptible sleep: wake immediately if stop_event fires + stop_event.wait(timeout=SCAN_INTERVAL) + + +# --------------- Main (standalone) ------------------ +def main() -> None: + state = {} + stop_event = threading.Event() + try: + run_watcher(state, stop_event) + except KeyboardInterrupt: + print("\nStopping...") + stop_event.set() if __name__ == "__main__": diff --git a/settings_dialog.py b/settings_dialog.py new file mode 100644 index 0000000..817a6b8 --- /dev/null +++ b/settings_dialog.py @@ -0,0 +1,459 @@ +""" +Series 3 Watcher — Settings Dialog v1.4.0 + +Provides a Tkinter settings dialog that doubles as a first-run wizard. + +Public API: + show_dialog(config_path, wizard=False) -> bool + Returns True if the user saved, False if they cancelled. + +Python 3.8 compatible — no walrus operators, no f-string = specifier, +no match statements, no 3.9+ syntax. +No external dependencies beyond stdlib + tkinter. +""" + +import os +import configparser +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from socket import gethostname + + +# --------------- Defaults (mirror config-template.ini) --------------- + +DEFAULTS = { + "API_ENABLED": "true", + "API_URL": "", + "API_INTERVAL_SECONDS": "300", + "SOURCE_ID": "", # empty = use hostname at runtime + "SOURCE_TYPE": "series3_watcher", + "SERIES3_PATH": r"C:\Blastware 10\Event\autocall home", + "MAX_EVENT_AGE_DAYS": "365", + "SCAN_INTERVAL_SECONDS":"300", + "OK_HOURS": "12", + "MISSING_HOURS": "24", + "MLG_HEADER_BYTES": "2048", + "ENABLE_LOGGING": "true", + "LOG_FILE": r"C:\SeismoEmitter\agent_logs\series3_watcher.log", + "LOG_RETENTION_DAYS": "30", + "COLORIZE": "false", +} + + +# --------------- Config I/O --------------- + +def _load_config(config_path): + """ + Load existing config.ini. Returns a flat dict of string values. + Falls back to DEFAULTS for any missing key. + """ + values = dict(DEFAULTS) + if not os.path.exists(config_path): + return values + + cp = configparser.ConfigParser(inline_comment_prefixes=(";", "#")) + cp.optionxform = str + try: + cp.read(config_path, encoding="utf-8") + except Exception: + return values + + # Accept either [agent] section or a bare file + section = None + if cp.has_section("agent"): + section = "agent" + elif cp.sections(): + section = cp.sections()[0] + + if section: + for k in DEFAULTS: + if cp.has_option(section, k): + values[k] = cp.get(section, k).strip() + + return values + + +def _save_config(config_path, values): + """Write all values to config_path under [agent] section.""" + cp = configparser.ConfigParser() + cp.optionxform = str + cp["agent"] = {} + for k, v in values.items(): + cp["agent"][k] = v + + config_dir = os.path.dirname(config_path) + if config_dir and not os.path.exists(config_dir): + os.makedirs(config_dir) + + with open(config_path, "w", encoding="utf-8") as f: + cp.write(f) + + +# --------------- Spinbox helper --------------- + +def _make_spinbox(parent, from_, to, width=8): + """Create a ttk.Spinbox; fall back to tk.Spinbox on older ttk.""" + try: + sb = ttk.Spinbox(parent, from_=from_, to=to, width=width) + except AttributeError: + # ttk.Spinbox added in Python 3.7 but not available everywhere + sb = tk.Spinbox(parent, from_=from_, to=to, width=width) + return sb + + +# --------------- Field helpers --------------- + +def _add_label_entry(frame, row, label_text, var, hint=None, readonly=False): + """Add a label + entry row to a grid frame. Returns the Entry widget.""" + tk.Label(frame, text=label_text, anchor="w").grid( + row=row, column=0, sticky="w", padx=(8, 4), pady=4 + ) + state = "readonly" if readonly else "normal" + entry = ttk.Entry(frame, textvariable=var, width=42, state=state) + entry.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4) + if hint and not var.get(): + # Show placeholder hint in grey; clear on focus + entry.config(foreground="grey") + entry.insert(0, hint) + + def _on_focus_in(event, e=entry, h=hint, v=var): + if e.get() == h: + e.delete(0, tk.END) + e.config(foreground="black") + + def _on_focus_out(event, e=entry, h=hint, v=var): + if not e.get(): + e.config(foreground="grey") + e.insert(0, h) + v.set("") + + entry.bind("", _on_focus_in) + entry.bind("", _on_focus_out) + return entry + + +def _add_label_spinbox(frame, row, label_text, var, from_, to): + """Add a label + spinbox row to a grid frame. Returns the Spinbox widget.""" + tk.Label(frame, text=label_text, anchor="w").grid( + row=row, column=0, sticky="w", padx=(8, 4), pady=4 + ) + sb = _make_spinbox(frame, from_=from_, to=to, width=8) + sb.grid(row=row, column=1, sticky="w", padx=(0, 8), pady=4) + sb.delete(0, tk.END) + sb.insert(0, var.get()) + + def _on_change(*args): + var.set(sb.get()) + + sb.config(command=_on_change) + sb.bind("", _on_change) + return sb + + +def _add_label_check(frame, row, label_text, var): + """Add a checkbox row to a grid frame.""" + cb = ttk.Checkbutton(frame, text=label_text, variable=var) + cb.grid(row=row, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=4) + return cb + + +def _add_label_browse_entry(frame, row, label_text, var, browse_fn): + """Add a label + entry + Browse button row.""" + tk.Label(frame, text=label_text, anchor="w").grid( + row=row, column=0, sticky="w", padx=(8, 4), pady=4 + ) + inner = tk.Frame(frame) + inner.grid(row=row, column=1, sticky="ew", padx=(0, 8), pady=4) + inner.columnconfigure(0, weight=1) + + entry = ttk.Entry(inner, textvariable=var, width=36) + entry.grid(row=0, column=0, sticky="ew") + btn = ttk.Button(inner, text="Browse...", command=browse_fn, width=9) + btn.grid(row=0, column=1, padx=(4, 0)) + return entry + + +# --------------- Main dialog class --------------- + +class SettingsDialog: + def __init__(self, parent, config_path, wizard=False): + self.config_path = config_path + self.wizard = wizard + self.saved = False + + self.root = parent + if wizard: + self.root.title("Series 3 Watcher — Setup") + else: + self.root.title("Series 3 Watcher — Settings") + self.root.resizable(False, False) + + # Center on screen + self.root.update_idletasks() + + self._values = _load_config(config_path) + self._build_vars() + self._build_ui() + + # Make dialog modal + self.root.grab_set() + self.root.protocol("WM_DELETE_WINDOW", self._on_cancel) + + # --- Variable setup --- + + def _build_vars(self): + v = self._values + + # Connection + self.var_api_enabled = tk.BooleanVar(value=v["API_ENABLED"].lower() in ("1","true","yes","on")) + self.var_api_url = tk.StringVar(value=v["API_URL"]) + self.var_api_interval = tk.StringVar(value=v["API_INTERVAL_SECONDS"]) + self.var_source_id = tk.StringVar(value=v["SOURCE_ID"]) + self.var_source_type = tk.StringVar(value=v["SOURCE_TYPE"]) + + # Paths + self.var_series3_path = tk.StringVar(value=v["SERIES3_PATH"]) + self.var_max_event_age_days = tk.StringVar(value=v["MAX_EVENT_AGE_DAYS"]) + self.var_log_file = tk.StringVar(value=v["LOG_FILE"]) + + # Scanning + self.var_scan_interval = tk.StringVar(value=v["SCAN_INTERVAL_SECONDS"]) + self.var_ok_hours = tk.StringVar(value=v["OK_HOURS"]) + self.var_missing_hours = tk.StringVar(value=v["MISSING_HOURS"]) + self.var_mlg_header_bytes = tk.StringVar(value=v["MLG_HEADER_BYTES"]) + + # 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 --- + + def _build_ui(self): + outer = tk.Frame(self.root, padx=10, pady=8) + outer.pack(fill="both", expand=True) + + if self.wizard: + welcome = ( + "Welcome to Series 3 Watcher!\n\n" + "No configuration file was found. Please review the settings below\n" + "and click \"Save & Start\" when you are ready." + ) + lbl = tk.Label( + outer, text=welcome, justify="left", + wraplength=460, fg="#1a5276", font=("TkDefaultFont", 9, "bold"), + ) + lbl.pack(fill="x", pady=(0, 8)) + + # Notebook + nb = ttk.Notebook(outer) + nb.pack(fill="both", expand=True) + + self._build_tab_connection(nb) + self._build_tab_paths(nb) + self._build_tab_scanning(nb) + self._build_tab_logging(nb) + + # Buttons + btn_frame = tk.Frame(outer) + btn_frame.pack(fill="x", pady=(10, 0)) + + save_label = "Save & Start" if self.wizard else "Save" + btn_save = ttk.Button(btn_frame, text=save_label, command=self._on_save, width=14) + btn_save.pack(side="right", padx=(4, 0)) + + btn_cancel = ttk.Button(btn_frame, text="Cancel", command=self._on_cancel, width=10) + btn_cancel.pack(side="right") + + def _tab_frame(self, nb, title): + """Create a new tab in nb, return a scrollable inner frame.""" + outer = tk.Frame(nb, padx=4, pady=4) + nb.add(outer, text=title) + outer.columnconfigure(1, weight=1) + return outer + + def _build_tab_connection(self, nb): + f = self._tab_frame(nb, "Connection") + + _add_label_check(f, 0, "API Enabled", self.var_api_enabled) + + _add_label_entry( + f, 1, "terra-view URL", self.var_api_url, + hint="http://192.168.x.x:8000/api/heartbeat", + ) + + _add_label_spinbox(f, 2, "API Interval (sec)", self.var_api_interval, 30, 3600) + + source_id_hint = "Defaults to hostname ({})".format(gethostname()) + _add_label_entry(f, 3, "Source ID", self.var_source_id, hint=source_id_hint) + + _add_label_entry(f, 4, "Source Type", self.var_source_type, readonly=True) + + def _build_tab_paths(self, nb): + f = self._tab_frame(nb, "Paths") + + def browse_series3(): + d = filedialog.askdirectory( + title="Select Blastware Event Folder", + initialdir=self.var_series3_path.get() or "C:\\", + ) + if d: + self.var_series3_path.set(d.replace("/", "\\")) + + _add_label_browse_entry(f, 0, "Series3 Path", self.var_series3_path, browse_series3) + _add_label_spinbox(f, 1, "Max Event Age (days)", self.var_max_event_age_days, 1, 3650) + + def browse_log(): + p = filedialog.asksaveasfilename( + title="Select Log File", + defaultextension=".log", + filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")], + initialfile=os.path.basename(self.var_log_file.get() or "series3_watcher.log"), + initialdir=os.path.dirname(self.var_log_file.get() or "C:\\"), + ) + if p: + self.var_log_file.set(p.replace("/", "\\")) + + _add_label_browse_entry(f, 2, "Log File", self.var_log_file, browse_log) + + def _build_tab_scanning(self, nb): + f = self._tab_frame(nb, "Scanning") + _add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600) + _add_label_spinbox(f, 1, "OK Hours", self.var_ok_hours, 1, 168) + _add_label_spinbox(f, 2, "Missing Hours", self.var_missing_hours, 1, 168) + _add_label_spinbox(f, 3, "MLG Header Bytes", self.var_mlg_header_bytes, 256, 65536) + + def _build_tab_logging(self, nb): + 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 --- + + def _get_int_var(self, var, name, min_val, max_val, default): + """Parse a StringVar as int, clamp to range, return clamped value or None on error.""" + raw = var.get().strip() + try: + val = int(raw) + except ValueError: + messagebox.showerror( + "Validation Error", + "{} must be an integer (got: {!r}).".format(name, raw), + ) + return None + if val < min_val or val > max_val: + messagebox.showerror( + "Validation Error", + "{} must be between {} and {} (got {}).".format(name, min_val, max_val, val), + ) + return None + return val + + # --- Save / Cancel --- + + def _on_save(self): + # Validate numeric fields before writing + checks = [ + (self.var_api_interval, "API Interval", 30, 3600, 300), + (self.var_max_event_age_days, "Max Event Age Days", 1, 3650, 365), + (self.var_scan_interval, "Scan Interval", 10, 3600, 300), + (self.var_ok_hours, "OK Hours", 1, 168, 12), + (self.var_missing_hours, "Missing Hours", 1, 168, 24), + (self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048), + (self.var_log_retention_days, "Log Retention Days", 1, 365, 30), + ] + int_values = {} + for var, name, mn, mx, dflt in checks: + result = self._get_int_var(var, name, mn, mx, dflt) + if result is None: + return # validation failed; keep dialog open + int_values[name] = result + + # Resolve source_id placeholder + source_id = self.var_source_id.get().strip() + # Strip placeholder hint if user left it + if source_id.startswith("Defaults to hostname"): + source_id = "" + + # Resolve api_url placeholder + api_url = self.var_api_url.get().strip() + if api_url.startswith("http://192.168"): + # This is the hint — only keep it if it looks like a real URL + pass # leave as-is; user may have typed it intentionally + if api_url == "http://192.168.x.x:8000/api/heartbeat": + api_url = "" + + values = { + "API_ENABLED": "true" if self.var_api_enabled.get() else "false", + "API_URL": api_url, + "API_INTERVAL_SECONDS": str(int_values["API Interval"]), + "SOURCE_ID": source_id, + "SOURCE_TYPE": self.var_source_type.get().strip() or "series3_watcher", + "SERIES3_PATH": self.var_series3_path.get().strip(), + "MAX_EVENT_AGE_DAYS": str(int_values["Max Event Age Days"]), + "SCAN_INTERVAL_SECONDS":str(int_values["Scan Interval"]), + "OK_HOURS": str(int_values["OK Hours"]), + "MISSING_HOURS": str(int_values["Missing Hours"]), + "MLG_HEADER_BYTES": str(int_values["MLG Header Bytes"]), + "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: + _save_config(self.config_path, values) + except Exception as e: + messagebox.showerror("Save Error", "Could not write config.ini:\n{}".format(e)) + return + + self.saved = True + self.root.destroy() + + def _on_cancel(self): + self.saved = False + self.root.destroy() + + +# --------------- Public API --------------- + +def show_dialog(config_path, wizard=False): + """ + Open the settings dialog. + + Parameters + ---------- + config_path : str + Absolute path to config.ini (read if it exists, written on Save). + wizard : bool + If True, shows first-run welcome message and "Save & Start" button. + + Returns + ------- + bool + True if the user saved, False if they cancelled. + """ + root = tk.Tk() + root.withdraw() # hide blank root window + + # Create a Toplevel that acts as the dialog window + top = tk.Toplevel(root) + top.deiconify() + + dlg = SettingsDialog(top, config_path, wizard=wizard) + + # Center after build + top.update_idletasks() + w = top.winfo_reqwidth() + h = top.winfo_reqheight() + sw = top.winfo_screenwidth() + sh = top.winfo_screenheight() + x = (sw - w) // 2 + y = (sh - h) // 2 + top.geometry("{}x{}+{}+{}".format(w, h, x, y)) + + root.wait_window(top) + root.destroy() + + return dlg.saved -- 2.49.1 From 1b8c63025f9eeddb0f32b7b76ed61420c2d98fa9 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Fri, 13 Mar 2026 17:52:59 -0400 Subject: [PATCH 04/12] doc: update readme v1.4 --- README.md | 182 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index d92c78e..c140a90 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,136 @@ -# Series 3 Watcher v1.3 +# Series 3 Watcher v1.4 -A lightweight Python script that monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. +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. -It scans the event folder, reads `.MLG` headers to identify unit IDs, and prints a live status table showing: +--- -- Last event received -- Age since last call-in -- OK / Pending / Missing states -- Detected units (no roster required) -- Optional API heartbeat to Seismograph Fleet Manager backend +## Deployment (Recommended — Installer) -This script is part of the larger **Seismograph Fleet Manager** project. +The easiest way to deploy to a field machine is the pre-built Windows installer. + +1. Download `series3-watcher-setup.exe` from the [latest release](https://gitea.serversdown.net/serversdown/series3-watcher/releases) on Gitea. +2. Run the installer on the target machine. It installs to `C:\Program Files\Series3Watcher\` and adds a shortcut to the user's Startup folder. +3. On first launch the **Setup Wizard** opens automatically — fill in the terra-view URL and Blastware path, then click **Save & Start**. +4. A coloured dot appears in the system tray. Done. + +The watcher will auto-start on every login from that point on. + +### Auto-Updates + +The watcher checks [Gitea](https://gitea.serversdown.net/serversdown/series3-watcher) for a newer release approximately every 5 minutes. When a newer `.exe` is found it downloads it silently, swaps the file, and relaunches — no user action required. + +Updates can also be pushed remotely from terra-view → **Settings → Developer → Watcher Manager**. + +--- + +## Building from Source + +Prerequisites (on a **Python 3.8** machine — required for Win7 compatibility): + +``` +pip install pystray Pillow pyinstaller +``` + +Then run: + +``` +build.bat +``` + +This produces `dist\series3-watcher.exe`. To build the installer, open `installer.iss` in [Inno Setup](https://jrsoftware.org/isinfo.php) and click Build. + +--- + +## Running Without the Installer (Dev / Debug) + +``` +pip install -r requirements.txt +python series3_tray.py # tray app (recommended) +python series3_watcher.py # console-only, no tray +``` + +`config.ini` must exist in the same directory. Copy `config-template.ini` to `config.ini` and edit it, or just run `series3_tray.py` — the wizard will create it on first run. + +--- + +## Configuration + +All settings live in `config.ini`. The Setup Wizard covers every field, but here's the reference: + +### API / terra-view + +| Key | Description | +|-----|-------------| +| `API_ENABLED` | `true` to send heartbeats to terra-view | +| `API_URL` | terra-view heartbeat endpoint, e.g. `http://192.168.1.10:8000/api/series3/heartbeat` | +| `API_INTERVAL_SECONDS` | How often to POST (default `300`) | +| `SOURCE_ID` | Identifier for this machine (defaults to hostname) | +| `SOURCE_TYPE` | Always `series3_watcher` | + +### Paths + +| Key | Description | +|-----|-------------| +| `SERIES3_PATH` | Blastware autocall folder, e.g. `C:\Blastware 10\Event\autocall home` | +| `MAX_EVENT_AGE_DAYS` | Ignore `.MLG` files older than this (default `365`) | +| `LOG_FILE` | Path to the log file | + +### Scanning + +| Key | Description | +|-----|-------------| +| `SCAN_INTERVAL_SECONDS` | How often to scan the folder (default `300`) | +| `OK_HOURS` | Age threshold for OK status (default `12`) | +| `MISSING_HOURS` | Age threshold for Missing status (default `24`) | +| `MLG_HEADER_BYTES` | Bytes to read from each `.MLG` header for unit ID (default `2048`) | +| `RECENT_WARN_DAYS` | Log unsniffable files newer than this window | + +### Logging + +| Key | Description | +|-----|-------------| +| `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 | + +--- + +## Tray Icon + +| Colour | Meaning | +|--------|---------| +| Grey | Starting / no scan yet | +| Green | All detected units OK | +| Yellow | At least one unit Pending | +| Red | At least one unit Missing, or error | + +Right-click the icon for: status, per-unit list, Settings, Open Log Folder, Exit. + +--- + +## terra-view Integration + +When `API_ENABLED = true`, the watcher POSTs a telemetry payload to terra-view on each heartbeat interval. terra-view updates the emitter table and tracks the watcher process itself (version, last seen, log tail) in the Watcher Manager. + +To view connected watchers: **Settings → Developer → Watcher Manager**. --- ## Requirements -- Python 3.8 (Windows 7 compatible) -- Blastware 10 event folder available locally -- `config.ini` in the same directory as the script - -Install dependencies with: - -`pip install -r requirements.txt` - ---- - -## Usage - -Run the agent from the folder containing the script: - -`python series3_watcher.py` - -The script will: - -1. Scan the Blastware event folder for `.MLG` files (within a max age window). -2. Sniff each file header for the unit ID. -3. Print a status line for each detected unit (OK / Pending / Missing). -4. Optionally POST a heartbeat payload on an interval when `API_ENABLED=true`. -5. Write logs into the `agent_logs/` folder and auto-clean old logs. - ---- - -## Config - -All settings are stored in `config.ini`. - -Key fields: - -- `SERIES3_PATH` — folder containing `.MLG` files -- `SCAN_INTERVAL_SECONDS` — how often to scan -- `OK_HOURS` / `MISSING_HOURS` — thresholds for status -- `MLG_HEADER_BYTES` — how many bytes to sniff from each `.MLG` header -- `RECENT_WARN_DAYS` — log unsniffable files newer than this window -- `MAX_EVENT_AGE_DAYS` — ignore events older than this many days -- `API_ENABLED` — enable/disable heartbeat POST -- `API_URL` — heartbeat endpoint -- `API_INTERVAL_SECONDS` — heartbeat frequency -- `SOURCE_ID` / `SOURCE_TYPE` — identifiers included in the API payload -- `LOG_RETENTION_DAYS` — auto-delete logs older than this many days -- `COLORIZE` — ANSI color output (off by default for Win7) - ---- - -## Logs - -Logs are stored under `agent_logs/`. -Git ignores all log files but keeps the folder itself. +- Windows 7 or later +- Python 3.8 (only needed if running from source — not needed with the installer) +- Blastware 10 event folder accessible on the local machine --- ## Versioning -This repo follows **Semantic Versioning (SemVer)**. - -Current release: **v1.3.0** — renamed to series3-watcher. -See `CHANGELOG.md` for details. +Follows **Semantic Versioning**. Current release: **v1.4.0**. +See `CHANGELOG.md` for full history. --- ## License -Private / internal project. +Private / internal — Terra-Mechanics Inc. -- 2.49.1 From e67b6eb89f76ec750685fe7791c0c4f101c8f5ee Mon Sep 17 00:00:00 2001 From: serversdwn Date: Mon, 16 Mar 2026 20:00:42 -0400 Subject: [PATCH 05/12] Feat: v1.4.1 - Windows installer updated. --- .gitignore | 2 + build.bat | 8 +++- config-template.ini | 2 +- icon.ico | Bin 0 -> 370070 bytes installer.iss | 6 ++- series3_tray.py | 50 +++++++++++++++++++++++-- series3_watcher.py | 25 +++++++++++-- settings_dialog.py | 88 +++++++++++++++++++++++++++++++++++++++----- 8 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 icon.ico diff --git a/.gitignore b/.gitignore index f5121db..1b6014b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ env/ # Distribution / packaging build/ dist/ +Output/ *.egg-info/ +*.spec # ------------------------- # Logs + runtime artifacts diff --git a/build.bat b/build.bat index 4fd4dee..17a0472 100644 --- a/build.bat +++ b/build.bat @@ -3,9 +3,13 @@ echo Building series3-watcher.exe... pip install pyinstaller pystray Pillow REM Check whether icon.ico exists alongside this script. -REM If it does, include it; otherwise build without a custom icon. +REM If it does, embed it as the .exe icon AND bundle it as a data file +REM so the tray overlay can load it at runtime. if exist "%~dp0icon.ico" ( - pyinstaller --onefile --windowed --name series3-watcher --icon="%~dp0icon.ico" series3_tray.py + pyinstaller --onefile --windowed --name series3-watcher ^ + --icon="%~dp0icon.ico" ^ + --add-data "%~dp0icon.ico;." ^ + series3_tray.py ) else ( echo [INFO] icon.ico not found -- building without custom icon. pyinstaller --onefile --windowed --name series3-watcher series3_tray.py diff --git a/config-template.ini b/config-template.ini index dbcd591..3d69ae6 100644 --- a/config-template.ini +++ b/config-template.ini @@ -19,7 +19,7 @@ MISSING_HOURS = 24 # Logging ENABLE_LOGGING = True -LOG_FILE = C:\SeismoEmitter\agent_logs\series3_watcher.log +LOG_FILE = C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log LOG_RETENTION_DAYS = 30 # Console colors - (Doesn't work on windows 7) diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..cce0f2a1531db40ee32999928bbb665a8944bbf2 GIT binary patch literal 370070 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 VHQ8LIIewoj)gf^ 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

LuhZK6mtTFUYQ)!t*@N*vv40M{qYV(Q zwXgqneMGyKvU3flFyx*rdug1lw;bsU-gsqy+qG*KS9QraQtf$e_a4ZmNFSlmri~l* zUhur8#lDC35xD2u=i?YX)%jI#JXvbeM0DP8COwxeTjpx);|*l1 z%x-hFo|3j{0Q~E|pL2ikBl58T<}|*V?)FcR-kei!|7&|VPrO;ZdbJ)Gq`p34*DrPR z7c5v1&WlN-_n<3Pu~O`DFtEQ1p4e{<|Ep8}sZ%TZPLhVSZR)uf)_7a%Cc>T~o#c!6 z+>h&2mi&Isb34Dlz9>2~Pm?B1Oi0F*-}#J=-`f96KP;FeK69Y|Q(6VuV=aB`ZauG; z@xM2;2EcUyjs?Q_moWg|bDW^Peh*%u-|bad1F-hk@_OYfSxu*rk-_!2EZDs`N`3X^ zR}r+N&hNF?T^Fif@+#+kTkASjK1!7s_@2syZDXy|fHgope!;vyFmjv9YI`H(k}PX% ztjYV4BSuE#hks}r)SEB-ICh@62EKB|iirFOE?{}cQ5;nzzUt=G~> zXT@^+bf|;EF_HC~g8Dz8{{UMVU^aAS3Mvm~I^;=Kdx@+d?$fDLr*@&_$;_Ygy@`Lb z?`L03S`w2%?=4YPWnQ)vx_{`yfoa{R<4WvX@h^P9?yBbung>9~aO6&vec8)cdpr+w z0_f%;%@M;#Mb#~BVb!WhI&#gBxaHNE50D20sx?(5yU$l8VaGUo)Nd+#*iR~}_ez!7 z508}&@V_4I zmDn#2|AA9j+ChK#p@$i5xK9&4LtAN-4>*0B-_K7y_0%XCAlpb`?Mr5PPdu@;wnt~B z*N71XzgNvY{RRJQGfw#D`W^M{APcNMJb?Li<^$vb-pmpzP^qEHtkG5l z4sE8qIpq^^6Kj2Z;GS@`xj~JWTS&E7t^^?4`ykW2)ZK7T1$cJjtwt`6cha z{^~1rRGc;IROKPLcX8#|;zXv?PQ(E~>90C0Q8}9c_`M=;__wgbVG_X%T;M2MS zxXQi$4}OAm!zyF9sj;u`Qa@~m&ac`>@~I7wK{aaFB!qd6NrG|$dmi9CFG!9y8LA7` z!6;v%6a2sR)?0-#_h%W-IQtvraR-CDNXD5?+{`u!!*~Cm$9<1?D z2kD^)A0l}o+6(7Pr*$~Zk6gH=M=b>9`~PWJL^|fwdsXOlVgxvS%mNbPsCV&e871= zkPJzI&&G=#hF_6=W=|HeX#ZGHvkQs@yvIKgP0e0E^hvTxBadT z?NF{?A5nhYui?qt@4T%JIfOdi_TT3B^HWYfMX!rX-SGMY)@0-9hvs*_njhahthZ|R z8Lz$eT1s>RQ=fk){IjnG=B)Vl;H+S8IpuE$y`T}hm3J7%0D^z=0L=$@4UG$MW#8@v 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 Dict[str, Any]: "OK_HOURS": float(get_int("OK_HOURS", 12)), "MISSING_HOURS": float(get_int("MISSING_HOURS", 24)), "ENABLE_LOGGING": get_bool("ENABLE_LOGGING", True), - "LOG_FILE": get_str("LOG_FILE", r"C:\SeismoEmitter\agent_logs\series3_watcher.log"), + "LOG_FILE": get_str("LOG_FILE", r"C:\Program Files\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)), @@ -216,7 +217,19 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.4.0" +VERSION = "1.4.1" + + +def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: + """Return the last n lines of the log file as a list of strings, or None on failure.""" + if not log_file: + return None + try: + with open(log_file, "r", errors="replace") as f: + lines = f.readlines() + return [l.rstrip("\n") for l in lines[-n:]] + except Exception: + return None def send_api_payload(payload: dict, api_url: str) -> Optional[dict]: @@ -301,7 +314,10 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: state["log_dir"] — directory containing the log file state["cfg"] — loaded config dict """ - here = os.path.dirname(__file__) or "." + if getattr(sys, "frozen", False): + here = os.path.dirname(os.path.abspath(sys.executable)) + else: + here = os.path.dirname(os.path.abspath(__file__)) or "." config_path = os.path.join(here, "config.ini") state["status"] = "starting" @@ -441,6 +457,7 @@ 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["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 diff --git a/settings_dialog.py b/settings_dialog.py index 817a6b8..2013a00 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -34,7 +34,7 @@ DEFAULTS = { "MISSING_HOURS": "24", "MLG_HEADER_BYTES": "2048", "ENABLE_LOGGING": "true", - "LOG_FILE": r"C:\SeismoEmitter\agent_logs\series3_watcher.log", + "LOG_FILE": r"C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log", "LOG_RETENTION_DAYS": "30", "COLORIZE": "false", } @@ -206,7 +206,12 @@ class SettingsDialog: # Connection self.var_api_enabled = tk.BooleanVar(value=v["API_ENABLED"].lower() in ("1","true","yes","on")) - self.var_api_url = tk.StringVar(value=v["API_URL"]) + # Strip the fixed endpoint suffix so the dialog shows just the base URL + _raw_url = v["API_URL"] + _suffix = "/api/series3/heartbeat" + if _raw_url.endswith(_suffix): + _raw_url = _raw_url[:-len(_suffix)] + self.var_api_url = tk.StringVar(value=_raw_url) self.var_api_interval = tk.StringVar(value=v["API_INTERVAL_SECONDS"]) self.var_source_id = tk.StringVar(value=v["SOURCE_ID"]) self.var_source_type = tk.StringVar(value=v["SOURCE_TYPE"]) @@ -277,10 +282,40 @@ class SettingsDialog: _add_label_check(f, 0, "API Enabled", self.var_api_enabled) - _add_label_entry( - f, 1, "terra-view URL", self.var_api_url, - hint="http://192.168.x.x:8000/api/heartbeat", + # URL row — entry + Test button in an inner frame + tk.Label(f, text="Terra-View URL", anchor="w").grid( + row=1, column=0, sticky="w", padx=(8, 4), pady=4 ) + url_frame = tk.Frame(f) + url_frame.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4) + url_frame.columnconfigure(0, weight=1) + + url_entry = ttk.Entry(url_frame, textvariable=self.var_api_url, width=32) + url_entry.grid(row=0, column=0, sticky="ew") + + # Placeholder hint behaviour + _hint = "http://192.168.x.x:8000" + if not self.var_api_url.get(): + url_entry.config(foreground="grey") + url_entry.insert(0, _hint) + def _on_focus_in(e): + if url_entry.get() == _hint: + url_entry.delete(0, tk.END) + url_entry.config(foreground="black") + def _on_focus_out(e): + if not url_entry.get(): + url_entry.config(foreground="grey") + url_entry.insert(0, _hint) + self.var_api_url.set("") + url_entry.bind("", _on_focus_in) + url_entry.bind("", _on_focus_out) + + self._test_btn = ttk.Button(url_frame, text="Test", width=6, + command=self._test_connection) + self._test_btn.grid(row=0, column=1, padx=(4, 0)) + + self._test_status = tk.Label(url_frame, text="", anchor="w", width=20) + self._test_status.grid(row=0, column=2, padx=(6, 0)) _add_label_spinbox(f, 2, "API Interval (sec)", self.var_api_interval, 30, 3600) @@ -289,6 +324,40 @@ class SettingsDialog: _add_label_entry(f, 4, "Source Type", self.var_source_type, readonly=True) + def _test_connection(self): + """POST a minimal ping to the Terra-View heartbeat endpoint and show result.""" + import urllib.request + import urllib.error + + self._test_status.config(text="Testing...", foreground="grey") + self._test_btn.config(state="disabled") + self.root.update_idletasks() + + raw = self.var_api_url.get().strip() + if not raw or raw == "http://192.168.x.x:8000": + self._test_status.config(text="Enter a URL first", foreground="orange") + self._test_btn.config(state="normal") + return + + url = raw.rstrip("/") + "/health" + + try: + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=5) as resp: + if resp.status == 200: + self._test_status.config(text="Connected!", foreground="green") + else: + self._test_status.config( + text="HTTP {}".format(resp.status), foreground="orange" + ) + except urllib.error.URLError as e: + reason = str(e.reason) if hasattr(e, "reason") else str(e) + self._test_status.config(text="Failed: {}".format(reason[:30]), foreground="red") + except Exception as e: + self._test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red") + finally: + self._test_btn.config(state="normal") + def _build_tab_paths(self, nb): f = self._tab_frame(nb, "Paths") @@ -376,13 +445,12 @@ class SettingsDialog: if source_id.startswith("Defaults to hostname"): source_id = "" - # Resolve api_url placeholder + # Resolve api_url — append the fixed endpoint, strip placeholder api_url = self.var_api_url.get().strip() - if api_url.startswith("http://192.168"): - # This is the hint — only keep it if it looks like a real URL - pass # leave as-is; user may have typed it intentionally - if api_url == "http://192.168.x.x:8000/api/heartbeat": + if api_url == "http://192.168.x.x:8000" or not api_url: api_url = "" + else: + api_url = api_url.rstrip("/") + "/api/series3/heartbeat" values = { "API_ENABLED": "true" if self.var_api_enabled.get() else "false", -- 2.49.1 From 504ee1d4707c3159579efd2a3093ec271c8e98d0 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 01:10:40 -0400 Subject: [PATCH 06/12] bugfix: log directory now writes to appdata folder, avoiding permissions issues. log folder accessible from tray icon. doc: deployment/build doc added --- BUILDING.md | 117 ++++++++++++++++++++++++++++++++++++++++++++ config-template.ini | 2 +- series3_tray.py | 14 ++++-- series3_watcher.py | 5 +- settings_dialog.py | 5 +- 5 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 BUILDING.md diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..ab5abac --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,117 @@ +# Building & Releasing Series 3 Watcher + +## Prerequisites (Win7 VM — do this once) + +- Python 3.7.2 (or 3.8.10 if SP1 is installed) +- Inno Setup 6 — installed at `C:\Program Files (x86)\Inno Setup 6\` +- PyInstaller, pystray, Pillow — installed automatically by `build.bat` + +The Win7 VM is the build machine. All builds must happen there to ensure +compatibility with the production DL2 computer. + +--- + +## First-Time Install on a New Machine + +Do this when setting up a brand new machine that has never had the watcher before. + +**Step 1 — Build the .exe (on the Win7 VM)** + +1. Copy the `series3-watcher/` folder to the VM (shared folder, USB, etc.) +2. Double-click `build.bat` +3. Wait for it to finish — output: `dist\series3-watcher.exe` + +**Step 2 — Build the installer (on the Win7 VM)** + +1. Open `installer.iss` in Inno Setup Compiler +2. Click **Build → Compile** +3. Output: `Output\series3-watcher-setup.exe` + +**Step 3 — Create a Gitea release** + +1. On your main machine, go to `https://gitea.serversdown.net/serversdown/series3-watcher` +2. Click **Releases → New Release** +3. Set the tag to match the version in `series3_watcher.py` (e.g. `v1.4.1`) +4. Upload **both** files as release assets: + - `dist\series3-watcher.exe` — used by the auto-updater on existing installs + - `Output\series3-watcher-setup.exe` — used for fresh installs + +**Step 4 — Install on the target machine** + +1. Download `series3-watcher-setup.exe` from the Gitea release +2. Run it on the target machine — installs to `C:\Program Files\Series3Watcher\` +3. The watcher launches automatically after install (or on next login) +4. The Setup Wizard appears on first run — fill in the Terra-View URL and Blastware path + +--- + +## Releasing an Update (existing machines auto-update) + +Do this for any code change — bug fix, new feature, etc. + +**Step 1 — Bump the version** + +In `series3_watcher.py`, update the `VERSION` string: +```python +VERSION = "1.4.2" # increment appropriately +``` +Also update `installer.iss`: +``` +AppVersion=1.4.2 +``` + +**Step 2 — Build the .exe (on the Win7 VM)** + +1. Pull the latest code to the VM +2. Double-click `build.bat` +3. Output: `dist\series3-watcher.exe` + +> For hotfixes you can skip Inno Setup — existing machines only need the `.exe`. +> Only rebuild the installer if you need a fresh install package for a new machine. + +**Step 3 — Create a Gitea release** + +1. Go to `https://gitea.serversdown.net/serversdown/series3-watcher` +2. Click **Releases → New Release** +3. Tag must match the new version exactly (e.g. `v1.4.2`) — the auto-updater + compares this tag against its own version to decide whether to update +4. Upload `dist\series3-watcher.exe` as a release asset +5. Optionally upload `Output\series3-watcher-setup.exe` if you rebuilt the installer + +**Step 4 — Done** + +Existing installs check Gitea every ~5 minutes. When they see the new tag they +will download `series3-watcher.exe`, swap it in place, and relaunch silently. +No user action required on the target machine. + +--- + +## Version Numbering + +Follows Semantic Versioning: `MAJOR.MINOR.PATCH` + +| Change type | Example | +|-------------|---------| +| Bug fix / text change | `1.4.1 → 1.4.2` | +| New feature | `1.4.x → 1.5.0` | +| Breaking change | `1.x.x → 2.0.0` | + +--- + +## Files That Go in the Gitea Release + +| File | Required for | Notes | +|------|-------------|-------| +| `dist\series3-watcher.exe` | Auto-updates on existing machines | Always upload this | +| `Output\series3-watcher-setup.exe` | Fresh installs on new machines | Only needed for new deployments | + +--- + +## Files That Are NOT Committed to Git + +- `dist/` — PyInstaller output +- `Output/` — Inno Setup output +- `build/` — PyInstaller temp files +- `*.spec` — PyInstaller spec file +- `config.ini` — machine-specific, never commit +- `agent_logs/` — log files diff --git a/config-template.ini b/config-template.ini index 3d69ae6..5c5376c 100644 --- a/config-template.ini +++ b/config-template.ini @@ -19,7 +19,7 @@ MISSING_HOURS = 24 # Logging ENABLE_LOGGING = True -LOG_FILE = C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log +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) diff --git a/series3_tray.py b/series3_tray.py index 454e498..1dea283 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -126,13 +126,21 @@ def apply_update(download_url): # --------------- Paths --------------- -# When frozen by PyInstaller, __file__ points to a temp extraction dir. -# sys.executable is always the real .exe location — use that instead. +# Executable location — used for bundled assets (icon.ico etc.) if getattr(sys, "frozen", False): HERE = os.path.dirname(os.path.abspath(sys.executable)) else: HERE = os.path.dirname(os.path.abspath(__file__)) -CONFIG_PATH = os.path.join(HERE, "config.ini") + +# config.ini lives in AppData so normal users can write it without UAC issues. +# Fall back to the exe directory when running from source (dev mode). +if getattr(sys, "frozen", False): + _appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or HERE + CONFIG_DIR = os.path.join(_appdata, "Series3Watcher") + os.makedirs(CONFIG_DIR, exist_ok=True) +else: + CONFIG_DIR = HERE +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.ini") # --------------- Icon drawing --------------- diff --git a/series3_watcher.py b/series3_watcher.py index 7ab22ad..d3772c5 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -63,7 +63,10 @@ def load_config(path: str) -> Dict[str, Any]: "OK_HOURS": float(get_int("OK_HOURS", 12)), "MISSING_HOURS": float(get_int("MISSING_HOURS", 24)), "ENABLE_LOGGING": get_bool("ENABLE_LOGGING", True), - "LOG_FILE": get_str("LOG_FILE", r"C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log"), + "LOG_FILE": get_str("LOG_FILE", os.path.join( + os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\", + "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)), diff --git a/settings_dialog.py b/settings_dialog.py index 2013a00..a5414d4 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -34,7 +34,10 @@ DEFAULTS = { "MISSING_HOURS": "24", "MLG_HEADER_BYTES": "2048", "ENABLE_LOGGING": "true", - "LOG_FILE": r"C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log", + "LOG_FILE": os.path.join( + os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\", + "Series3Watcher", "agent_logs", "series3_watcher.log" + ), "LOG_RETENTION_DAYS": "30", "COLORIZE": "false", } -- 2.49.1 From 326658ed26df5fd964b5e0f69c805ef645711b5e Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 01:23:01 -0400 Subject: [PATCH 07/12] doc: readme bummped to 1.4.1 --- README.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c140a90..824b220 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.4 +# Series 3 Watcher v1.4.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. @@ -23,21 +23,12 @@ Updates can also be pushed remotely from terra-view → **Settings → Developer --- -## Building from Source +## Building & Releasing -Prerequisites (on a **Python 3.8** machine — required for Win7 compatibility): - -``` -pip install pystray Pillow pyinstaller -``` - -Then run: - -``` -build.bat -``` - -This produces `dist\series3-watcher.exe`. To build the installer, open `installer.iss` in [Inno Setup](https://jrsoftware.org/isinfo.php) and click Build. +See [BUILDING.md](BUILDING.md) for the full step-by-step process covering: +- First-time build and installer creation +- Publishing a release to Gitea +- Releasing hotfix updates (auto-updater picks them up automatically) --- @@ -62,7 +53,7 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here | Key | Description | |-----|-------------| | `API_ENABLED` | `true` to send heartbeats to terra-view | -| `API_URL` | terra-view heartbeat endpoint, e.g. `http://192.168.1.10:8000/api/series3/heartbeat` | +| `API_URL` | Terra-View base URL, e.g. `http://192.168.1.10:8000` — the `/api/series3/heartbeat` endpoint is appended automatically | | `API_INTERVAL_SECONDS` | How often to POST (default `300`) | | `SOURCE_ID` | Identifier for this machine (defaults to hostname) | | `SOURCE_TYPE` | Always `series3_watcher` | @@ -126,7 +117,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.4.0**. +Follows **Semantic Versioning**. Current release: **v1.4.1**. See `CHANGELOG.md` for full history. --- -- 2.49.1 From f773e1dac9333b48e424245557ff7c420d5f193a Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 03:27:53 -0400 Subject: [PATCH 08/12] fix: tray icon more legible --- series3_tray.py | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/series3_tray.py b/series3_tray.py index 1dea283..d60261d 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -154,50 +154,11 @@ COLORS = { } ICON_SIZE = 64 -ICON_FILE = os.path.join(HERE, "icon.ico") - -# Load base icon once at startup; fall back to None if missing. -# When frozen, bundled data files live in sys._MEIPASS. -def _load_base_icon(): - candidates = [ICON_FILE] - if getattr(sys, "frozen", False): - candidates.insert(0, os.path.join(sys._MEIPASS, "icon.ico")) - for path in candidates: - try: - img = Image.open(path) - img = img.convert("RGBA").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS) - return img - except Exception: - continue - return None - -_BASE_ICON = _load_base_icon() def make_icon(status): - """ - Use icon.ico as the base and overlay a small status dot in the - bottom-right corner. Falls back to a plain colored circle if the - ico file is not available. - """ + """Draw a plain colored circle for the system tray — clean and readable at 16px.""" color = COLORS.get(status, COLORS["starting"]) - - if _BASE_ICON is not None: - img = _BASE_ICON.copy() - draw = ImageDraw.Draw(img) - # Dot size and position — bottom-right corner - dot_r = ICON_SIZE // 5 # radius ~12px on 64px icon - margin = 2 - x0 = ICON_SIZE - dot_r * 2 - margin - y0 = ICON_SIZE - dot_r * 2 - margin - x1 = ICON_SIZE - margin - y1 = ICON_SIZE - margin - # White outline for contrast on dark/light backgrounds - draw.ellipse([x0 - 2, y0 - 2, x1 + 2, y1 + 2], fill=(255, 255, 255, 220)) - draw.ellipse([x0, y0, x1, y1], fill=color + (255,)) - return img - - # Fallback: plain colored circle img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) margin = 6 -- 2.49.1 From 9cfdebe553babbee6a95e3ac4b4d9d7b3f13a953 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 03:40:04 -0400 Subject: [PATCH 09/12] fix: watcher correctly uses AppData directory, not program files. --- series3_watcher.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/series3_watcher.py b/series3_watcher.py index d3772c5..b2f72ca 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -319,9 +319,12 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None: """ if getattr(sys, "frozen", False): here = os.path.dirname(os.path.abspath(sys.executable)) + _appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or here + config_dir = os.path.join(_appdata, "Series3Watcher") else: here = os.path.dirname(os.path.abspath(__file__)) or "." - config_path = os.path.join(here, "config.ini") + config_dir = here + config_path = os.path.join(config_dir, "config.ini") state["status"] = "starting" state["units"] = [] -- 2.49.1 From 814b6f915eab5f16dfe6ea7438ace73b13073bd9 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 13:36:35 -0400 Subject: [PATCH 10/12] fix: settings dialog now runs in its own thread, increasing responsiveness. --- series3_tray.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/series3_tray.py b/series3_tray.py index d60261d..748125f 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -246,15 +246,13 @@ class WatcherTray: # --- Menu item callbacks --- def _open_settings(self, icon, item): - """Open the settings dialog. On save, restart watcher thread.""" - from settings_dialog import show_dialog - saved = show_dialog(CONFIG_PATH, wizard=False) - if saved: - self._restart_watcher() - # Rebuild menu so status label refreshes - if self._icon is not None: - with self._menu_lock: - self._icon.menu = self._build_menu() + """Open the settings dialog in its own thread so the tray stays responsive.""" + def _run(): + from settings_dialog import show_dialog + saved = show_dialog(CONFIG_PATH, wizard=False) + if saved: + self._restart_watcher() + threading.Thread(target=_run, daemon=True, name="settings-dialog").start() def _open_logs(self, icon, item): log_dir = self.state.get("log_dir") @@ -312,17 +310,12 @@ class WatcherTray: return pystray.Menu(*items) def _build_menu(self): - # Capture current text/submenu at build time; pystray will call - # callables each render, but static strings are fine for infrequent - # menu rebuilds. We use callables for the dynamic items so that the - # text shown on hover/open is current. - status_text = self._status_text() - units_submenu = self._build_units_submenu() - + # 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(status_text, None, enabled=False), + pystray.MenuItem(lambda item: self._status_text(), None, enabled=False), pystray.Menu.SEPARATOR, - pystray.MenuItem("Units", units_submenu), + 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), -- 2.49.1 From 1d94c5dd0456e440c1b2d6c4a049c1d745252bf3 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 14:26:59 -0400 Subject: [PATCH 11/12] docs: delete deprecated client specific readme --- README_DL2.md | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 README_DL2.md diff --git a/README_DL2.md b/README_DL2.md deleted file mode 100644 index 0f97dff..0000000 --- a/README_DL2.md +++ /dev/null @@ -1,26 +0,0 @@ -# Series 3 Watcher — v1_0(py38-safe) for DL2 - -**Target**: Windows 7 + Python 3.8.10 -**Baseline**: v5_4 (no logic changes) - -## Files -- series3_agent_v1_0_py38.py — main script (py38-safe) -- config.ini — your config (already included) -- series3_roster.csv — your roster (already included, this auto updates from a URL to a dropbox file) -- requirements.txt — none beyond stdlib - -## Install -1) Create `C:\SeismoEmitter\` on DL2 -2) Extract this ZIP into that folder -3) Open CMD: - ```cmd - cd C:\SeismoEmitter - python series3_agent_v1_0_py38.py - ``` -(If the console shows escape codes on Win7, set `COLORIZE = False` in `config.ini`.) - -## Quick validation -- Heartbeat prints Local/UTC timestamps -- One line per active roster unit with OK/Pending/Missing, Age, Last, File -- Unexpected units block shows .MLG not in roster -- agent.log rotates per LOG_RETENTION_DAYS -- 2.49.1 From d2fd3b7182ce1d0e5f7fe967c258a2bb7351820b Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 14:29:46 -0400 Subject: [PATCH 12/12] docs: v1.4.1 changelog entry --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b595a..b4d1dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.4.1] - 2026-03-17 + +### Fixed +- `config.ini` now saves to `AppData\Local\Series3Watcher\` instead of `Program Files` — fixes permission denied error on first-run wizard save. +- Config path resolution in both `series3_tray.py` and `series3_watcher.py` updated to use `sys.frozen` + `LOCALAPPDATA` when running as a PyInstaller `.exe`. +- Status menu item now uses a callable so it updates every time the menu opens — was showing stale "Starting..." while tooltip correctly showed current status. +- Settings dialog now opens in its own thread — fixes unresponsive tabs and text fields while the watcher loop is running. +- Tray icon reverted to plain colored dot — custom icon graphic was unreadable at 16px tray size. `.ico` file is still used for the `.exe` file icon. + +### Changed +- Terra-View URL field in settings wizard now accepts base URL only (e.g. `http://192.168.x.x:8000`) — `/api/series3/heartbeat` endpoint appended automatically. +- Test Connection button now hits `/health` endpoint instead of posting a fake heartbeat — no database side effects. +- "terra-view URL" label capitalized to "Terra-View URL". +- Default log path updated to `AppData\Local\Series3Watcher\agent_logs\series3_watcher.log`. +- Installer now creates `agent_logs\` folder on install. +- `BUILDING.md` added — step-by-step guide for building, releasing, and updating. + ## [1.4.0] - 2026-03-12 ### Added -- 2.49.1