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
|
## [1.5.0] - 2026-05-09
|
||||||
|
|
||||||
### Added
|
### 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.
|
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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user