feat(forward): SFM event forwarder (v1.5.0) #9

Merged
serversdown merged 9 commits from dev into main 2026-05-11 12:29:22 -04:00
6 changed files with 180 additions and 7 deletions
Showing only changes of commit 3ee0cae31e - Show all commits
+10
View File
@@ -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 `<log dir>/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 ## [1.5.0] - 2026-05-09
### Added ### Added
+3 -3
View File
@@ -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. 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_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. | | `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 `<binary>.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. Forwards each Blastware event binary (and its paired `<binary>.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 ## Versioning
Follows **Semantic Versioning**. Current release: **v1.5.0**. Follows **Semantic Versioning**. Current release: **v1.5.1**.
See `CHANGELOG.md` for full history. See `CHANGELOG.md` for full history.
--- ---
+1 -1
View File
@@ -3,7 +3,7 @@
[Setup] [Setup]
AppName=Series 3 Watcher AppName=Series 3 Watcher
AppVersion=1.5.0 AppVersion=1.5.1
AppPublisher=Terra-Mechanics Inc. AppPublisher=Terra-Mechanics Inc.
DefaultDirName={pf}\Series3Watcher DefaultDirName={pf}\Series3Watcher
DefaultGroupName=Series 3 Watcher DefaultGroupName=Series 3 Watcher
+1 -1
View File
@@ -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) Requires: pystray, Pillow, tkinter (stdlib)
Run with: pythonw series3_tray.py (no console window) Run with: pythonw series3_tray.py (no console window)
+1 -1
View File
@@ -241,7 +241,7 @@ def scan_latest(
# --- API heartbeat / SFM telemetry helpers --- # --- 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]: def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]:
+164 -1
View File
@@ -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. Provides a Tkinter settings dialog that doubles as a first-run wizard.
@@ -41,6 +41,17 @@ DEFAULTS = {
# Auto-updater # Auto-updater
"UPDATE_SOURCE": "gitea", "UPDATE_SOURCE": "gitea",
"UPDATE_URL": "", "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_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"]) 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 --- # --- UI construction ---
def _build_ui(self): def _build_ui(self):
@@ -264,6 +285,7 @@ class SettingsDialog:
self._build_tab_scanning(nb) self._build_tab_scanning(nb)
self._build_tab_logging(nb) self._build_tab_logging(nb)
self._build_tab_updates(nb) self._build_tab_updates(nb)
self._build_tab_sfm(nb)
# Buttons # Buttons
btn_frame = tk.Frame(outer) btn_frame = tk.Frame(outer)
@@ -462,6 +484,123 @@ class SettingsDialog:
else: else:
self._update_url_entry.config(state="disabled") 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 <log dir>/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 <sfm_url>/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 --- # --- Validation helpers ---
@@ -494,6 +633,10 @@ class SettingsDialog:
(self.var_scan_interval, "Scan Interval", 10, 3600, 300), (self.var_scan_interval, "Scan Interval", 10, 3600, 300),
(self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048), (self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048),
(self.var_log_retention_days, "Log Retention Days", 1, 365, 30), (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 = {} int_values = {}
for var, name, mn, mx, dflt in checks: for var, name, mn, mx, dflt in checks:
@@ -502,6 +645,17 @@ class SettingsDialog:
return # validation failed; keep dialog open return # validation failed; keep dialog open
int_values[name] = result 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 # Resolve source_id placeholder
source_id = self.var_source_id.get().strip() source_id = self.var_source_id.get().strip()
# Strip placeholder hint if user left it # Strip placeholder hint if user left it
@@ -530,6 +684,15 @@ class SettingsDialog:
"LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]), "LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]),
"UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea", "UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea",
"UPDATE_URL": self.var_update_url.get().strip(), "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: try: