feat(forward): SFM event forwarder (v1.5.0) #9
@@ -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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
Follows **Semantic Versioning**. Current release: **v1.5.0**.
|
||||
Follows **Semantic Versioning**. Current release: **v1.5.1**.
|
||||
See `CHANGELOG.md` for full history.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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]:
|
||||
|
||||
+164
-1
@@ -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 <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 ---
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user