feat: windows installer with remote updates and remote management added.

This commit is contained in:
serversdwn
2026-03-13 17:40:28 -04:00
parent 00956c022a
commit 0807e09047
7 changed files with 1037 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
"""
Series 3 Watcher — v1.3.0
Series 3 Watcher — v1.4.0
Environment:
- Python 3.8 (Windows 7 compatible)
@@ -12,15 +12,14 @@ Key Features:
- Safe .MLG header sniff for unit IDs (BE#### / BA####)
- Standardized SFM Telemetry JSON payload (source-agnostic)
- Periodic HTTP heartbeat POST to SFM backend
- NEW in v1.2.0:
- No local roster / CSV dependency
- Only scans .MLG files newer than MAX_EVENT_AGE_DAYS
- Tray-friendly: run_watcher(state, stop_event) for background thread use
"""
import os
import re
import time
import json
import threading
import configparser
import urllib.request
import urllib.error
@@ -95,7 +94,7 @@ def log_message(path: str, enabled: bool, msg: str) -> None:
with open(path, "a", encoding="utf-8") as f:
f.write("{} {}\n".format(datetime.now(timezone.utc).isoformat(), msg))
except Exception:
# Logging must never crash the agent
# Logging must never crash the watcher
pass
@@ -205,7 +204,7 @@ def scan_latest(
# If unsniffable but very recent, log for later inspection
if (recent_cutoff is not None) and (mtime >= recent_cutoff):
if logger:
logger(f"[unsniffable-recent] {fpath}")
logger("[unsniffable-recent] {}".format(fpath))
continue # skip file if no unit ID found in header
cache[fpath] = (mtime, uid)
@@ -217,16 +216,25 @@ def scan_latest(
# --- API heartbeat / SFM telemetry helpers ---
def send_api_payload(payload: dict, api_url: str) -> None:
VERSION = "1.4.0"
def send_api_payload(payload: dict, api_url: str) -> Optional[dict]:
"""POST payload to API. Returns parsed JSON response dict, or None on failure."""
if not api_url:
return
return None
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(api_url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as res:
print(f"[API] POST success: {res.status}")
print("[API] POST success: {}".format(res.status))
try:
return json.loads(res.read().decode("utf-8"))
except Exception:
return None
except urllib.error.URLError as e:
print(f"[API] POST failed: {e}")
print("[API] POST failed: {}".format(e))
return None
def build_sfm_payload(units_dict: Dict[str, Dict[str, Any]], cfg: Dict[str, Any]) -> dict:
@@ -280,10 +288,38 @@ def build_sfm_payload(units_dict: Dict[str, Dict[str, Any]], cfg: Dict[str, Any]
return payload
# --------------- Main loop ------------------
def main() -> None:
# --------------- Watcher loop (tray-friendly) ----------------
def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
"""
Main watcher loop. Runs in a background thread when launched from the tray.
state dict is written on every scan cycle:
state["status"] — "ok" | "pending" | "missing" | "error" | "starting"
state["units"] — list of dicts: {uid, status, age_hours, last, fname}
state["last_scan"] — datetime of last successful scan (or None)
state["last_error"] — last error string (or None)
state["log_dir"] — directory containing the log file
state["cfg"] — loaded config dict
"""
here = os.path.dirname(__file__) or "."
cfg = load_config(os.path.join(here, "config.ini"))
config_path = os.path.join(here, "config.ini")
state["status"] = "starting"
state["units"] = []
state["last_scan"] = None
state["last_error"] = None
state["log_dir"] = None
state["cfg"] = {}
try:
cfg = load_config(config_path)
except Exception as e:
state["status"] = "error"
state["last_error"] = "Config load failed: {}".format(e)
return
state["cfg"] = cfg
state["log_dir"] = os.path.dirname(cfg["LOG_FILE"]) or here
WATCH_PATH = cfg["WATCH_PATH"]
SCAN_INTERVAL = int(cfg["SCAN_INTERVAL"])
@@ -315,12 +351,10 @@ def main() -> None:
),
)
# cache for scanning
sniff_cache: Dict[str, Tuple[float, str]] = {}
last_api_ts: float = 0.0
while True:
while not stop_event.is_set():
try:
now_local = datetime.now().isoformat()
now_utc = datetime.now(timezone.utc).isoformat()
@@ -342,24 +376,41 @@ def main() -> None:
)
now_epoch = time.time()
# Detected units summary (no roster dependency)
# Build per-unit status list for the tray
unit_list = []
worst = "ok" # tracks worst status across all units
if latest:
print("\nDetected Units (within last {} days):".format(MAX_EVENT_AGE_DAYS))
for uid in sorted(latest.keys()):
info = latest[uid]
age_hours = (now_epoch - info["mtime"]) / 3600.0
if age_hours > MISSING_HOURS:
status, col = "Missing", C_MIS
status, col = "missing", C_MIS
elif age_hours > OK_HOURS:
status, col = "Pending", C_PEN
status, col = "pending", C_PEN
else:
status, col = "OK", C_OK
status, col = "ok", C_OK
# escalate worst status
if status == "missing":
worst = "missing"
elif status == "pending" and worst != "missing":
worst = "pending"
unit_list.append({
"uid": uid,
"status": status,
"age_hours": age_hours,
"last": fmt_last(info["mtime"]),
"fname": info["fname"],
})
line = (
"{col}{uid:<8} {status:<8} Age: {age:<7} Last: {last} (File: {fname}){rst}".format(
col=col,
uid=uid,
status=status,
status=status.capitalize(),
age=fmt_age(now_epoch, info["mtime"]),
last=fmt_last(info["mtime"]),
fname=info["fname"],
@@ -375,25 +426,47 @@ def main() -> None:
ENABLE_LOGGING,
"[info] no recent MLG activity within {} days".format(MAX_EVENT_AGE_DAYS),
)
worst = "missing"
# Update shared state for tray
state["status"] = worst
state["units"] = unit_list
state["last_scan"] = datetime.now()
state["last_error"] = None
# ---- API heartbeat to SFM ----
if cfg.get("API_ENABLED", False):
now_ts = time.time()
interval = int(cfg.get("API_INTERVAL_SECONDS", 300))
if now_ts - last_api_ts >= interval:
payload = build_sfm_payload(latest, cfg)
send_api_payload(payload, cfg.get("API_URL", ""))
hb_payload = build_sfm_payload(latest, cfg)
hb_payload["version"] = VERSION
response = send_api_payload(hb_payload, cfg.get("API_URL", ""))
last_api_ts = now_ts
# Surface update signal to tray
if response and response.get("update_available"):
state["update_available"] = True
except KeyboardInterrupt:
print("\nStopping...")
break
except Exception as e:
err = "[loop-error] {}".format(e)
print(err)
log_message(LOG_FILE, ENABLE_LOGGING, err)
state["status"] = "error"
state["last_error"] = str(e)
time.sleep(SCAN_INTERVAL)
# Interruptible sleep: wake immediately if stop_event fires
stop_event.wait(timeout=SCAN_INTERVAL)
# --------------- Main (standalone) ------------------
def main() -> None:
state = {}
stop_event = threading.Event()
try:
run_watcher(state, stop_event)
except KeyboardInterrupt:
print("\nStopping...")
stop_event.set()
if __name__ == "__main__":