From 3ee0cae31ed10e0f82edb13a8fa618f4245f585d Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 10 May 2026 00:01:25 +0000 Subject: [PATCH] fix(settings): add SFM Forward tab to settings dialog (v1.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5.0 shipped the forwarder module + INI keys but the settings dialog wasn't extended, so the only way operators could enable forwarding was hand-editing config.ini. This adds a sixth tab ("SFM Forward") with: - Forward events to SFM checkbox - SFM Server URL entry + Test button (GETs /health) - Forward Interval (sec) spinbox - Quiescence (sec) spinbox - Missing-Report Grace spinbox - HTTP Timeout spinbox - State File entry + Browse... Save-time guard: enabling the forwarder without a URL raises a validation error rather than silently saving a non-functional config. Patch release — same on-disk INI schema, no config migration. --- CHANGELOG.md | 10 +++ README.md | 6 +- installer.iss | 2 +- series3_tray.py | 2 +- series3_watcher.py | 2 +- settings_dialog.py | 165 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 180 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1daa983..a56f7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.5.1] - 2026-05-10 + +### Added +- **SFM Forward tab in the Settings dialog.** v1.5.0 shipped the `event_forwarder.py` module + INI keys but missed the GUI; operators had to edit `config.ini` by hand to enable forwarding. The settings dialog now exposes: + - **Forward events to SFM** checkbox + - **SFM Server URL** entry with a **Test** button (mirrors the Connection tab — GETs `/health` and shows the result) + - **Forward Interval / Quiescence / Missing-Report Grace / HTTP Timeout** spinboxes + - **State File** entry with a Browse... button (defaults to `/sfm_forwarded.json` when blank) +- Save-time guard: enabling SFM Forward without filling in the URL shows a validation error rather than silently saving a non-functional config. + ## [1.5.0] - 2026-05-09 ### Added diff --git a/README.md b/README.md index 8d80489..73c567c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.5.0 +# Series 3 Watcher v1.5.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. @@ -88,7 +88,7 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here | `UPDATE_SOURCE` | `gitea` (default) or `url` — where to check for updates | | `UPDATE_URL` | Base URL of the update server when `UPDATE_SOURCE = url` (e.g. terra-view URL). The watcher fetches `/api/updates/series3-watcher/version.txt` and `/api/updates/series3-watcher/series3-watcher.exe` from this base. | -### SFM Event Forwarder (v1.5.0+) +### SFM Event Forwarder (v1.5.1+) Forwards each Blastware event binary (and its paired `.TXT` ASCII report when present) to an SFM server's `/db/import/blastware_file` endpoint, where the report is parsed and the rich per-channel stats (PPV, ZC Freq, Time of Peak, Peak Acceleration / Displacement, sensor self-check) land in a searchable database. **Default-off** — existing deployments keep their old behaviour after auto-updating until the operator opts in. @@ -137,7 +137,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.5.0**. +Follows **Semantic Versioning**. Current release: **v1.5.1**. See `CHANGELOG.md` for full history. --- diff --git a/installer.iss b/installer.iss index 1625c87..d16051f 100644 --- a/installer.iss +++ b/installer.iss @@ -3,7 +3,7 @@ [Setup] AppName=Series 3 Watcher -AppVersion=1.5.0 +AppVersion=1.5.1 AppPublisher=Terra-Mechanics Inc. DefaultDirName={pf}\Series3Watcher DefaultGroupName=Series 3 Watcher diff --git a/series3_tray.py b/series3_tray.py index 555a2f6..142530a 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — System Tray Launcher v1.5.0 +Series 3 Watcher — System Tray Launcher v1.5.1 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw series3_tray.py (no console window) diff --git a/series3_watcher.py b/series3_watcher.py index ad93b70..63cdfeb 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -241,7 +241,7 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.5.0" +VERSION = "1.5.1" def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: diff --git a/settings_dialog.py b/settings_dialog.py index e06a4cc..4a3a987 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — Settings Dialog v1.4.4 +Series 3 Watcher — Settings Dialog v1.5.1 Provides a Tkinter settings dialog that doubles as a first-run wizard. @@ -41,6 +41,17 @@ DEFAULTS = { # Auto-updater "UPDATE_SOURCE": "gitea", "UPDATE_URL": "", + + # SFM event forwarder (default-off; existing 1.4.x deployments + # don't change behaviour after auto-update until an operator + # opts in by setting SFM_URL + flipping SFM_FORWARD_ENABLED). + "SFM_FORWARD_ENABLED": "false", + "SFM_URL": "", + "SFM_FORWARD_INTERVAL_SECONDS": "60", + "SFM_QUIESCENCE_SECONDS": "5", + "SFM_MISSING_REPORT_GRACE_SECONDS": "60", + "SFM_HTTP_TIMEOUT": "60", + "SFM_STATE_FILE": "", } @@ -237,6 +248,16 @@ class SettingsDialog: self.var_update_source = tk.StringVar(value=v["UPDATE_SOURCE"].lower() if v["UPDATE_SOURCE"].lower() in ("gitea", "url", "disabled") else "gitea") self.var_update_url = tk.StringVar(value=v["UPDATE_URL"]) + # SFM event forwarder + self.var_sfm_enabled = tk.BooleanVar( + value=v["SFM_FORWARD_ENABLED"].lower() in ("1", "true", "yes", "on")) + self.var_sfm_url = tk.StringVar(value=v["SFM_URL"]) + self.var_sfm_forward_interval = tk.StringVar(value=v["SFM_FORWARD_INTERVAL_SECONDS"]) + self.var_sfm_quiescence = tk.StringVar(value=v["SFM_QUIESCENCE_SECONDS"]) + self.var_sfm_missing_report_grace = tk.StringVar(value=v["SFM_MISSING_REPORT_GRACE_SECONDS"]) + self.var_sfm_http_timeout = tk.StringVar(value=v["SFM_HTTP_TIMEOUT"]) + self.var_sfm_state_file = tk.StringVar(value=v["SFM_STATE_FILE"]) + # --- UI construction --- def _build_ui(self): @@ -264,6 +285,7 @@ class SettingsDialog: self._build_tab_scanning(nb) self._build_tab_logging(nb) self._build_tab_updates(nb) + self._build_tab_sfm(nb) # Buttons btn_frame = tk.Frame(outer) @@ -462,6 +484,123 @@ class SettingsDialog: else: self._update_url_entry.config(state="disabled") + # ────────────────────────────────────────────────────────────────── + # SFM Forward tab + # ────────────────────────────────────────────────────────────────── + + def _build_tab_sfm(self, nb): + """Configure the SFM event forwarder. + + When enabled, every Blastware event binary in the watch folder + (plus its paired .TXT report when present) is POSTed to an SFM + server's /db/import/blastware_file endpoint. Default-off so + existing 1.4.x deployments don't change behaviour after an + auto-update — operator opts in by setting the URL and flipping + the checkbox. + """ + f = self._tab_frame(nb, "SFM Forward") + + _add_label_check(f, 0, "Forward events to SFM", self.var_sfm_enabled) + + # SFM URL row — entry + Test button (mirrors the Connection tab's pattern) + tk.Label(f, text="SFM Server 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) + + sfm_entry = ttk.Entry(url_frame, textvariable=self.var_sfm_url, width=32) + sfm_entry.grid(row=0, column=0, sticky="ew") + + self._sfm_test_btn = ttk.Button( + url_frame, text="Test", width=6, command=self._test_sfm_connection, + ) + self._sfm_test_btn.grid(row=0, column=1, padx=(4, 0)) + + self._sfm_test_status = tk.Label(url_frame, text="", anchor="w", width=20) + self._sfm_test_status.grid(row=0, column=2, padx=(6, 0)) + + _add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 5, 3600) + _add_label_spinbox(f, 3, "Quiescence (sec)", self.var_sfm_quiescence, 1, 60) + _add_label_spinbox(f, 4, "Missing-Report Grace (sec)", self.var_sfm_missing_report_grace, 0, 600) + _add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 600) + + tk.Label(f, text="State File", anchor="w").grid( + row=6, column=0, sticky="w", padx=(8, 4), pady=4 + ) + state_frame = tk.Frame(f) + state_frame.grid(row=6, column=1, sticky="ew", padx=(0, 8), pady=4) + state_frame.columnconfigure(0, weight=1) + + state_entry = ttk.Entry(state_frame, textvariable=self.var_sfm_state_file, width=32) + state_entry.grid(row=0, column=0, sticky="ew") + + def _browse_state(): + path = filedialog.asksaveasfilename( + title="SFM forward-state file", + defaultextension=".json", + filetypes=[("JSON", "*.json"), ("All Files", "*.*")], + initialfile="sfm_forwarded.json", + ) + if path: + self.var_sfm_state_file.set(path) + + ttk.Button(state_frame, text="Browse...", width=10, command=_browse_state).grid( + row=0, column=1, padx=(4, 0) + ) + + hint_text = ( + "Forwards every Blastware event binary (and its paired .TXT report)\n" + "to an SFM server, where the report is parsed for searchable\n" + "per-channel stats: PPV, ZC Freq, Time of Peak, Peak Acceleration,\n" + "Peak Displacement, sensor self-check, monitor log.\n\n" + "Idempotent: forwarded files are tracked by sha256 in the state\n" + "file; restarts and re-scans never re-POST. Leave State File blank\n" + "to default to /sfm_forwarded.json." + ) + tk.Label(f, text=hint_text, justify="left", fg="#555555", wraplength=380).grid( + row=7, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4) + ) + + def _test_sfm_connection(self): + """GET /health and show the result.""" + import urllib.request + import urllib.error + + self._sfm_test_status.config(text="Testing...", foreground="grey") + self._sfm_test_btn.config(state="disabled") + self.root.update_idletasks() + + raw = self.var_sfm_url.get().strip() + if not raw: + self._sfm_test_status.config(text="Enter a URL first", foreground="orange") + self._sfm_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._sfm_test_status.config(text="Connected!", foreground="green") + else: + self._sfm_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._sfm_test_status.config( + text="Failed: {}".format(reason[:30]), foreground="red", + ) + except Exception as e: + self._sfm_test_status.config( + text="Error: {}".format(str(e)[:30]), foreground="red", + ) + finally: + self._sfm_test_btn.config(state="normal") + # --- Validation helpers --- @@ -494,6 +633,10 @@ class SettingsDialog: (self.var_scan_interval, "Scan Interval", 10, 3600, 300), (self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048), (self.var_log_retention_days, "Log Retention Days", 1, 365, 30), + (self.var_sfm_forward_interval, "SFM Forward Interval", 5, 3600, 60), + (self.var_sfm_quiescence, "SFM Quiescence", 1, 60, 5), + (self.var_sfm_missing_report_grace, "SFM Missing-Report Grace", 0, 600, 60), + (self.var_sfm_http_timeout, "SFM HTTP Timeout", 5, 600, 60), ] int_values = {} for var, name, mn, mx, dflt in checks: @@ -502,6 +645,17 @@ class SettingsDialog: return # validation failed; keep dialog open int_values[name] = result + # SFM forwarding requires a URL when enabled — common foot-gun + # to flip the checkbox without filling in the field. + if self.var_sfm_enabled.get() and not self.var_sfm_url.get().strip(): + messagebox.showerror( + "Validation Error", + "SFM Forward is enabled but the SFM Server URL field is empty.\n\n" + "Either set the URL (e.g. http://10.0.0.44:8200) or uncheck " + "'Forward events to SFM'.", + ) + return + # Resolve source_id placeholder source_id = self.var_source_id.get().strip() # Strip placeholder hint if user left it @@ -530,6 +684,15 @@ class SettingsDialog: "LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]), "UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea", "UPDATE_URL": self.var_update_url.get().strip(), + + # SFM event forwarder + "SFM_FORWARD_ENABLED": "true" if self.var_sfm_enabled.get() else "false", + "SFM_URL": self.var_sfm_url.get().strip().rstrip("/"), + "SFM_FORWARD_INTERVAL_SECONDS": str(int_values["SFM Forward Interval"]), + "SFM_QUIESCENCE_SECONDS": str(int_values["SFM Quiescence"]), + "SFM_MISSING_REPORT_GRACE_SECONDS": str(int_values["SFM Missing-Report Grace"]), + "SFM_HTTP_TIMEOUT": str(int_values["SFM HTTP Timeout"]), + "SFM_STATE_FILE": self.var_sfm_state_file.get().strip(), } try: