Files
2026-08-03 11:47:09 -04:00

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())