feat: SFM event forwarding added. v0.3.0

This commit is contained in:
2026-05-19 04:31:35 +00:00
parent 4742ed92ba
commit ac8b58c193
10 changed files with 1947 additions and 28 deletions
+163 -5
View File
@@ -1,5 +1,5 @@
"""
Thor Watcher — Settings Dialog v0.2.0
Thor Watcher — Settings Dialog v0.3.0
Provides a Tkinter settings dialog that doubles as a first-run wizard.
@@ -14,6 +14,8 @@ import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from socket import gethostname
import series4_ingest as watcher
# ── Defaults (mirror config.example.json) ────────────────────────────────────
@@ -34,6 +36,17 @@ DEFAULTS = {
"log_retention_days": 30,
"update_source": "gitea",
"update_url": "",
# SFM forwarder defaults — mirror series4_ingest.load_config
"sfm_forward_enabled": False,
"sfm_url": "",
"sfm_forward_interval": 60,
"sfm_quiescence_seconds": 5,
"sfm_missing_report_grace_seconds": 60,
"sfm_http_timeout": 60,
"sfm_state_file": "",
"sfm_max_forwards_per_pass": 500,
"sfm_max_event_age_days": 365,
}
@@ -145,7 +158,8 @@ class SettingsDialog:
self.saved = False
self.root = parent
title = "Thor Watcher — Setup" if wizard else "Thor Watcher — Settings"
kind = "Setup" if wizard else "Settings"
title = "Thor Watcher v{}{}".format(watcher.VERSION, kind)
self.root.title(title)
self.root.resizable(False, False)
self.root.update_idletasks()
@@ -192,6 +206,20 @@ class SettingsDialog:
self.var_update_source = tk.StringVar(value=src)
self.var_update_url = tk.StringVar(value=str(v.get("update_url", "")))
# SFM Forwarder
sfm_en = v.get("sfm_forward_enabled", False)
self.var_sfm_enabled = tk.BooleanVar(
value=bool(sfm_en) if isinstance(sfm_en, bool) else str(sfm_en).lower() in ("true", "1", "yes")
)
self.var_sfm_url = tk.StringVar(value=str(v.get("sfm_url", "")))
self.var_sfm_forward_interval = tk.StringVar(value=str(v.get("sfm_forward_interval", 60)))
self.var_sfm_quiescence = tk.StringVar(value=str(v.get("sfm_quiescence_seconds", 5)))
self.var_sfm_grace = tk.StringVar(value=str(v.get("sfm_missing_report_grace_seconds", 60)))
self.var_sfm_http_timeout = tk.StringVar(value=str(v.get("sfm_http_timeout", 60)))
self.var_sfm_max_per_pass = tk.StringVar(value=str(v.get("sfm_max_forwards_per_pass", 500)))
self.var_sfm_max_age_days = tk.StringVar(value=str(v.get("sfm_max_event_age_days", 365)))
self.var_sfm_state_file = tk.StringVar(value=str(v.get("sfm_state_file", "")))
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
@@ -216,6 +244,7 @@ class SettingsDialog:
self._build_tab_paths(nb)
self._build_tab_scanning(nb)
self._build_tab_logging(nb)
self._build_tab_forwarding(nb)
self._build_tab_updates(nb)
btn_frame = tk.Frame(outer)
@@ -347,6 +376,114 @@ class SettingsDialog:
_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)
def _build_tab_forwarding(self, nb):
f = self._tab_frame(nb, "SFM Forward")
# Row 0: enable checkbox
_add_label_check(f, 0, "Enable SFM Forwarding", self.var_sfm_enabled)
# Row 1: SFM URL + Test button
tk.Label(f, text="SFM 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")
_hint = "http://10.0.0.44:8200"
if not self.var_sfm_url.get():
sfm_entry.config(foreground="grey")
sfm_entry.insert(0, _hint)
def _on_focus_in(e, ent=sfm_entry, h=_hint):
if ent.get() == h:
ent.delete(0, tk.END)
ent.config(foreground="black")
def _on_focus_out(e, ent=sfm_entry, h=_hint, v=self.var_sfm_url):
if not ent.get():
ent.config(foreground="grey")
ent.insert(0, h)
v.set("")
sfm_entry.bind("<FocusIn>", _on_focus_in)
sfm_entry.bind("<FocusOut>", _on_focus_out)
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))
# Rows 2-7: timing/limits spinboxes
_add_label_spinbox(f, 2, "Forward Interval (sec)", self.var_sfm_forward_interval, 30, 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_grace, 0, 3600)
_add_label_spinbox(f, 5, "HTTP Timeout (sec)", self.var_sfm_http_timeout, 5, 300)
_add_label_spinbox(f, 6, "Max Forwards Per Pass", self.var_sfm_max_per_pass, 1, 5000)
_add_label_spinbox(f, 7, "Max Event Age (days)", self.var_sfm_max_age_days, 1, 3650)
# Row 8: state file browse
def browse_state():
p = filedialog.asksaveasfilename(
title="Select SFM State File",
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialfile=os.path.basename(self.var_sfm_state_file.get() or "thor_forwarded.json"),
initialdir=os.path.dirname(self.var_sfm_state_file.get() or "C:\\"),
)
if p:
self.var_sfm_state_file.set(p.replace("/", "\\"))
_add_label_browse_entry(f, 8, "State File", self.var_sfm_state_file, browse_state)
# Row 9: help text
help_text = (
"Forwards .IDFH (histogram) and .IDFW (waveform) event files plus their\n"
"TXT/<basename>.txt sidecars to a seismo-relay SFM server.\n"
"Idempotent: each file is tracked by sha256, so re-scans never re-POST.\n"
"If the TXT sidecar appears AFTER the binary was forwarded alone, the\n"
"next pass will re-forward so the relay can refresh the DB row with\n"
"device-authoritative PPV/ZCFreq/peak values.\n"
"State file blank → defaults to <log_dir>\\thor_forwarded.json."
)
tk.Label(
f, text=help_text, justify="left", fg="#555555", wraplength=420,
).grid(row=9, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 4))
def _test_sfm_connection(self):
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 or raw == "http://10.0.0.44:8200":
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:
with urllib.request.urlopen(urllib.request.Request(url), 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")
def _build_tab_updates(self, nb):
f = self._tab_frame(nb, "Updates")
@@ -421,9 +558,15 @@ class SettingsDialog:
def _on_save(self):
checks = [
(self.var_api_interval, "API Interval", 30, 3600),
(self.var_scan_interval, "Scan Interval", 10, 3600),
(self.var_log_retention_days, "Log Retention Days", 1, 365),
(self.var_api_interval, "API Interval", 30, 3600),
(self.var_scan_interval, "Scan Interval", 10, 3600),
(self.var_log_retention_days, "Log Retention Days", 1, 365),
(self.var_sfm_forward_interval, "Forward Interval", 30, 3600),
(self.var_sfm_quiescence, "Quiescence", 1, 60),
(self.var_sfm_grace, "Missing-Report Grace", 0, 3600),
(self.var_sfm_http_timeout, "HTTP Timeout", 5, 300),
(self.var_sfm_max_per_pass, "Max Forwards Per Pass", 1, 5000),
(self.var_sfm_max_age_days, "Max Event Age (days)", 1, 3650),
]
int_values = {}
for var, name, mn, mx in checks:
@@ -442,6 +585,11 @@ class SettingsDialog:
else:
api_url = api_url.rstrip("/") + "/api/series4/heartbeat"
sfm_url = self.var_sfm_url.get().strip()
if sfm_url == "http://10.0.0.44:8200":
sfm_url = ""
sfm_url = sfm_url.rstrip("/") # event_forwarder adds the endpoint path
values = {
"thordata_path": self.var_thordata_path.get().strip(),
"scan_interval": int_values["Scan Interval"],
@@ -456,6 +604,16 @@ class SettingsDialog:
"log_retention_days": int_values["Log Retention Days"],
"update_source": self.var_update_source.get().strip() or "gitea",
"update_url": self.var_update_url.get().strip(),
"sfm_forward_enabled": self.var_sfm_enabled.get(),
"sfm_url": sfm_url,
"sfm_forward_interval": int_values["Forward Interval"],
"sfm_quiescence_seconds": int_values["Quiescence"],
"sfm_missing_report_grace_seconds": int_values["Missing-Report Grace"],
"sfm_http_timeout": int_values["HTTP Timeout"],
"sfm_max_forwards_per_pass": int_values["Max Forwards Per Pass"],
"sfm_max_event_age_days": int_values["Max Event Age (days)"],
"sfm_state_file": self.var_sfm_state_file.get().strip(),
}
try: