264 lines
9.1 KiB
Python
Executable File
264 lines
9.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Collect Deco clients, persist snapshots to MongoDB, and maintain local network history.
|
|
|
|
This script is intended to be run by Hermes cron via the companion shell wrapper
|
|
`deco_network_watchdog.sh` every 5 minutes. It is deliberately non-agentic and
|
|
prints nothing on success so no_agent cron runs stay silent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REYNA_DIR = Path("/home/adolforeyna/reyna-cli")
|
|
STATE_DIR = Path.home() / ".hermes" / "deco_network"
|
|
LAST_STATE_FILE = STATE_DIR / "last_state.json"
|
|
HISTOGRAM_FILE = STATE_DIR / "histogram_1h.json"
|
|
DECO_CHANGES_FILE = STATE_DIR / "linked_deco_changes.jsonl"
|
|
NEW_DEVICES_FILE = STATE_DIR / "new_devices.jsonl"
|
|
FETCH_ERRORS_FILE = STATE_DIR / "fetch_errors.jsonl"
|
|
SNAPSHOT_FILE = STATE_DIR / "latest_snapshot.json"
|
|
MONGO_DB = os.environ.get("DECO_NETWORK_MONGO_DB", "reyna_home")
|
|
MONGO_COLLECTION = os.environ.get("DECO_NETWORK_MONGO_COLLECTION", "deco_client_snapshots")
|
|
WINDOW = timedelta(hours=1)
|
|
FETCH_TIMEOUT_SECONDS = 45
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def iso(dt: datetime) -> str:
|
|
return dt.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def parse_iso(value: str) -> datetime:
|
|
if value.endswith("Z"):
|
|
value = value[:-1] + "+00:00"
|
|
return datetime.fromisoformat(value)
|
|
|
|
|
|
def load_json(path: Path, default: Any) -> Any:
|
|
try:
|
|
if path.exists():
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
# Keep the collector running even if a local state file is corrupt.
|
|
backup = path.with_suffix(path.suffix + f".corrupt-{int(utc_now().timestamp())}")
|
|
try:
|
|
path.replace(backup)
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def atomic_write_json(path: Path, data: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(path.name + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, sort_keys=True, default=str), encoding="utf-8")
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def append_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
|
if not records:
|
|
return
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("a", encoding="utf-8") as handle:
|
|
for record in records:
|
|
handle.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
|
|
|
|
|
def fetch_deco_clients() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
# The shell wrapper runs this under `uv run python` from REYNA_DIR, so the
|
|
# project dependencies are available. Query Deco directly from reyna-cli
|
|
# instead of depending on the Mac mini MCP endpoint.
|
|
sys.path.insert(0, str(REYNA_DIR / "src"))
|
|
from reyna_cli.deco_direct import DecoDirectClient
|
|
|
|
data = DecoDirectClient().list_clients()
|
|
clients = data.get("clients") if isinstance(data, dict) else None
|
|
if not isinstance(clients, list):
|
|
raise RuntimeError("deco clients result did not contain clients list")
|
|
return clients, data
|
|
|
|
|
|
def device_key(client: dict[str, Any]) -> str:
|
|
return str(client.get("mac") or client.get("ip") or client.get("hostname") or "unknown")
|
|
|
|
|
|
def compact_device(client: dict[str, Any]) -> dict[str, Any]:
|
|
keys = [
|
|
"hostname",
|
|
"mac",
|
|
"ip",
|
|
"connection",
|
|
"interface",
|
|
"active",
|
|
"linkedDecoMac",
|
|
"linkedDecoName",
|
|
"linkedDecoRole",
|
|
]
|
|
return {key: client.get(key) for key in keys if client.get(key) is not None}
|
|
|
|
|
|
def maintain_local_files(now: datetime, clients: list[dict[str, Any]], raw_result: dict[str, Any]) -> None:
|
|
now_s = iso(now)
|
|
current: dict[str, dict[str, Any]] = {device_key(client): compact_device(client) for client in clients}
|
|
last_state = load_json(LAST_STATE_FILE, {})
|
|
if not isinstance(last_state, dict):
|
|
last_state = {}
|
|
|
|
deco_changes: list[dict[str, Any]] = []
|
|
new_devices: list[dict[str, Any]] = []
|
|
for key, current_device in current.items():
|
|
previous = last_state.get(key)
|
|
if not previous:
|
|
new_devices.append({"timestamp": now_s, "device_key": key, "device": current_device})
|
|
continue
|
|
old_deco = previous.get("linkedDecoName")
|
|
new_deco = current_device.get("linkedDecoName")
|
|
if old_deco != new_deco:
|
|
deco_changes.append(
|
|
{
|
|
"timestamp": now_s,
|
|
"device_key": key,
|
|
"hostname": current_device.get("hostname"),
|
|
"mac": current_device.get("mac"),
|
|
"ip": current_device.get("ip"),
|
|
"old_linkedDecoName": old_deco,
|
|
"new_linkedDecoName": new_deco,
|
|
"old_linkedDecoMac": previous.get("linkedDecoMac"),
|
|
"new_linkedDecoMac": current_device.get("linkedDecoMac"),
|
|
}
|
|
)
|
|
|
|
append_jsonl(DECO_CHANGES_FILE, deco_changes)
|
|
append_jsonl(NEW_DEVICES_FILE, new_devices)
|
|
atomic_write_json(LAST_STATE_FILE, current)
|
|
|
|
histogram = load_json(HISTOGRAM_FILE, {"buckets": []})
|
|
buckets = histogram.get("buckets", []) if isinstance(histogram, dict) else []
|
|
cutoff = now - WINDOW
|
|
kept_buckets = []
|
|
for bucket in buckets:
|
|
try:
|
|
if parse_iso(bucket["timestamp"]) >= cutoff:
|
|
kept_buckets.append(bucket)
|
|
except Exception:
|
|
continue
|
|
|
|
bucket_devices = sorted(current)
|
|
kept_buckets.append(
|
|
{
|
|
"timestamp": now_s,
|
|
"client_count": len(clients),
|
|
"devices": bucket_devices,
|
|
"linkedDecoCounts": dict(sorted(_counts(c.get("linkedDecoName") for c in current.values()).items())),
|
|
}
|
|
)
|
|
|
|
device_summary: dict[str, dict[str, Any]] = {}
|
|
per_device_buckets: dict[str, list[str]] = defaultdict(list)
|
|
for bucket in kept_buckets:
|
|
for key in bucket.get("devices", []):
|
|
per_device_buckets[key].append(bucket.get("timestamp"))
|
|
for key, seen_timestamps in sorted(per_device_buckets.items()):
|
|
device_summary[key] = {
|
|
"seen_count": len(seen_timestamps),
|
|
"first_seen": seen_timestamps[0],
|
|
"last_seen": seen_timestamps[-1],
|
|
"currently_present": key in current,
|
|
"device": current.get(key) or last_state.get(key) or {},
|
|
}
|
|
|
|
atomic_write_json(
|
|
HISTOGRAM_FILE,
|
|
{
|
|
"generated_at": now_s,
|
|
"window_minutes": 60,
|
|
"bucket_interval_minutes": 5,
|
|
"bucket_count": len(kept_buckets),
|
|
"current_client_count": len(clients),
|
|
"current_devices": bucket_devices,
|
|
"new_devices_this_run": [record["device"] for record in new_devices],
|
|
"linkedDeco_changes_this_run": deco_changes,
|
|
"devices": device_summary,
|
|
"buckets": kept_buckets,
|
|
},
|
|
)
|
|
atomic_write_json(
|
|
SNAPSHOT_FILE,
|
|
{
|
|
"timestamp": now_s,
|
|
"client_count": len(clients),
|
|
"clients": clients,
|
|
"decos": raw_result.get("decos", []),
|
|
},
|
|
)
|
|
|
|
|
|
def _counts(values: Any) -> dict[str, int]:
|
|
counts: dict[str, int] = defaultdict(int)
|
|
for value in values:
|
|
counts[str(value or "unknown")] += 1
|
|
return counts
|
|
|
|
|
|
def insert_mongo_snapshot(now: datetime, clients: list[dict[str, Any]], raw_result: dict[str, Any]) -> None:
|
|
sys.path.insert(0, str(REYNA_DIR / "src"))
|
|
from reyna_cli.mongo_direct import MongoDirectClient
|
|
|
|
now_s = iso(now)
|
|
mongo = MongoDirectClient(timeout_ms=5000)
|
|
collection = mongo.client[MONGO_DB][MONGO_COLLECTION]
|
|
collection.insert_one(
|
|
{
|
|
"timestamp": now,
|
|
"timestamp_iso": now_s,
|
|
"source": "reyna-cli macmini deco clients",
|
|
"client_count": len(clients),
|
|
"clients": clients,
|
|
"decos": raw_result.get("decos", []),
|
|
}
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
now = utc_now()
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
clients, raw_result = fetch_deco_clients()
|
|
except Exception as exc:
|
|
# Treat a temporarily unreachable Mac mini / MCP endpoint as a data
|
|
# collection miss, not as a cron failure. no_agent cron jobs alert on
|
|
# non-zero exits, and Wi-Fi/LAN outages would otherwise spam WhatsApp.
|
|
append_jsonl(
|
|
FETCH_ERRORS_FILE,
|
|
[
|
|
{
|
|
"timestamp": iso(now),
|
|
"stage": "fetch_deco_clients",
|
|
"error_type": type(exc).__name__,
|
|
"error": str(exc),
|
|
}
|
|
],
|
|
)
|
|
return 0
|
|
insert_mongo_snapshot(now, clients, raw_result)
|
|
maintain_local_files(now, clients, raw_result)
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"deco_network_watchdog error: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|