feat: windows installer with remote updates and remote management added.
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user