#!/usr/bin/env python3 """ WiFi Recon Dashboard Reads from events.db (written by udp_ingest.py), serves a live web UI. """ import asyncio import sqlite3 import datetime import os import json from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse DB_PATH = os.path.join(os.path.dirname(__file__), "events.db") HTML_PATH = os.path.join(os.path.dirname(__file__), "templates", "dashboard.html") CSS_PATH = os.path.join(os.path.dirname(__file__), "static", "dashboard.css") JS_PATH = os.path.join(os.path.dirname(__file__), "static", "dashboard.js") OUI_PATH = os.path.join(os.path.dirname(__file__), "oui.txt") ONLINE_SECS = 30 # node considered online if heartbeat within this many seconds EVENT_CAP = 100 # max events returned / shown in dashboard # ─── OUI lookup ────────────────────────────────────────────────────────────── def _load_oui(path: str) -> dict[str, str]: db: dict[str, str] = {} if not os.path.exists(path): return db with open(path, encoding="utf-8", errors="ignore") as f: for line in f: if "(hex)" not in line: continue parts = line.split("(hex)") prefix = parts[0].strip().replace("-", ":").upper() # "AA-BB-CC" → "AA:BB:CC" vendor = parts[1].strip() if prefix and vendor: db[prefix] = vendor return db _OUI_DB: dict[str, str] = _load_oui(OUI_PATH) def oui_lookup(mac: str) -> tuple[str, bool]: """ Returns (vendor_name, is_randomized). Randomized MACs have the locally-administered bit set (bit 1 of first byte). OUI lookup is meaningless for randomized MACs. """ if not mac or len(mac) < 8: return ("Unknown", False) try: first_byte = int(mac.replace(":", "").replace("-", "")[:2], 16) randomized = bool(first_byte & 0x02) except ValueError: return ("Unknown", False) if randomized: return ("Randomized MAC", True) prefix = mac.upper()[:8] return (_OUI_DB.get(prefix, "Unknown"), False) # ─── DB helpers ────────────────────────────────────────────────────────────── def get_conn() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def query(sql: str, params: tuple = ()) -> list[dict]: with get_conn() as conn: rows = conn.execute(sql, params).fetchall() return [dict(r) for r in rows] def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str: if not last_seen: return "offline" try: dt = datetime.datetime.fromisoformat(last_seen) delta = (datetime.datetime.utcnow() - dt).total_seconds() return "online" if delta <= threshold else "offline" except ValueError: return "offline" # ─── API data builders ──────────────────────────────────────────────────────── def build_nodes() -> list[dict]: rows = query(""" SELECT node_id, MAX(received_at) AS last_beacon, COUNT(*) AS total_events, MIN(received_at) AS first_seen FROM beacon_events GROUP BY node_id ORDER BY last_beacon DESC """) # Latest heartbeat per node hb_rows = query(""" SELECT node_id, received_at AS last_heartbeat, uptime_ms, free_heap, wifi_rssi FROM heartbeat_events WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id) """) hb_map = {r["node_id"]: r for r in hb_rows} for r in rows: hb = hb_map.get(r["node_id"], {}) r["last_heartbeat"] = hb.get("last_heartbeat") r["uptime_ms"] = hb.get("uptime_ms") r["free_heap"] = hb.get("free_heap") r["wifi_rssi"] = hb.get("wifi_rssi") r["last_seen"] = r["last_heartbeat"] or r["last_beacon"] # Online if heartbeat within 30s; fall back to beacon within 60s if no heartbeat yet if r["last_heartbeat"]: r["status"] = _node_status(r["last_heartbeat"], threshold=30) else: r["status"] = _node_status(r["last_beacon"], threshold=60) return rows def build_events(limit: int = EVENT_CAP, node_id: str | None = None) -> list[dict]: if node_id: return query(""" SELECT * FROM beacon_events WHERE node_id = ? ORDER BY id DESC LIMIT ? """, (node_id, limit)) return query(""" SELECT * FROM beacon_events ORDER BY id DESC LIMIT ? """, (limit,)) def build_networks() -> list[dict]: rows = query(""" SELECT ssid, bssid, MAX(rssi) AS best_rssi, ROUND(AVG(rssi), 1) AS avg_rssi, channel, encryption, COUNT(*) AS times_seen, COUNT(DISTINCT node_id) AS node_count, MAX(received_at) AS last_seen FROM beacon_events WHERE bssid IS NOT NULL GROUP BY bssid ORDER BY times_seen DESC """) return rows def build_clients() -> list[dict]: """ Per unique src_mac: aggregated stats and the SSIDs they were probing for. """ rows = query(""" SELECT src_mac, COUNT(*) AS times_seen, COUNT(DISTINCT node_id) AS node_count, MAX(rssi) AS best_rssi, ROUND(AVG(rssi), 1) AS avg_rssi, COUNT(DISTINCT NULLIF(ssid,'')) AS unique_ssids, MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM probe_events WHERE src_mac IS NOT NULL GROUP BY src_mac ORDER BY times_seen DESC """) # Fetch all probed SSIDs in one query, group in Python — avoids N+1 ssid_rows = query(""" SELECT DISTINCT src_mac, ssid FROM probe_events WHERE src_mac IS NOT NULL AND ssid IS NOT NULL AND ssid != '' ORDER BY src_mac, ssid """) ssids_by_mac: dict = {} for s in ssid_rows: ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) for r in rows: r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], []) vendor, randomized = oui_lookup(r["src_mac"]) r["vendor"] = vendor r["randomized"] = randomized return rows def build_cross_node() -> dict: """ For every BSSID, return per-node RSSI stats side by side. Result: { node_ids: [...], networks: [{bssid, ssid, channel, encryption, node_count, nodes: {node_id: {best_rssi, avg_rssi, times_seen}}}] } """ node_rows = query("SELECT DISTINCT node_id FROM beacon_events ORDER BY node_id") node_ids = [r["node_id"] for r in node_rows] rows = query(""" SELECT bssid, MAX(ssid) AS ssid, MAX(channel) AS channel, MAX(encryption) AS encryption, node_id, MAX(rssi) AS best_rssi, ROUND(AVG(rssi), 1) AS avg_rssi, COUNT(*) AS times_seen FROM beacon_events WHERE bssid IS NOT NULL GROUP BY bssid, node_id ORDER BY bssid, node_id """) networks: dict = {} for r in rows: bssid = r["bssid"] if bssid not in networks: networks[bssid] = { "bssid": bssid, "ssid": r["ssid"], "channel": r["channel"], "encryption": r["encryption"], "nodes": {}, } networks[bssid]["nodes"][r["node_id"]] = { "best_rssi": r["best_rssi"], "avg_rssi": r["avg_rssi"], "times_seen": r["times_seen"], } result = sorted( networks.values(), key=lambda x: len(x["nodes"]), reverse=True, ) for net in result: net["node_count"] = len(net["nodes"]) return {"node_ids": node_ids, "networks": result} def build_presence() -> dict: now = datetime.datetime.utcnow() cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds") cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds") # ── Present now: real MACs seen 2+ times in the last hour ──────────────── present_rows = query(""" SELECT src_mac, COUNT(*) AS times_seen, MAX(rssi) AS best_rssi, ROUND(AVG(rssi), 1) AS avg_rssi, MAX(received_at) AS last_seen, COUNT(DISTINCT node_id) AS node_count FROM probe_events WHERE src_mac IS NOT NULL AND received_at > ? GROUP BY src_mac HAVING COUNT(*) > 1 ORDER BY last_seen DESC """, (cutoff_1h,)) present = [] for r in present_rows: vendor, randomized = oui_lookup(r["src_mac"]) if not randomized: r["vendor"] = vendor present.append(r) # ── New arrivals: first seen in last 24h ───────────────────────────────── new_mac_rows = query(""" SELECT src_mac, MIN(received_at) AS first_seen, MAX(rssi) AS best_rssi, COUNT(*) AS times_seen, COUNT(DISTINCT node_id) AS node_count FROM probe_events WHERE src_mac IS NOT NULL GROUP BY src_mac HAVING MIN(received_at) > ? ORDER BY first_seen DESC LIMIT 100 """, (cutoff_24h,)) new_macs = [] for r in new_mac_rows: vendor, randomized = oui_lookup(r["src_mac"]) r["vendor"] = vendor r["randomized"] = randomized new_macs.append(r) new_networks = query(""" SELECT bssid, MAX(ssid) AS ssid, MIN(received_at) AS first_seen, MAX(rssi) AS best_rssi, MAX(channel) AS channel, MAX(encryption) AS encryption, COUNT(DISTINCT node_id) AS node_count FROM beacon_events WHERE bssid IS NOT NULL GROUP BY bssid HAVING MIN(received_at) > ? ORDER BY first_seen DESC LIMIT 100 """, (cutoff_24h,)) # ── Regulars: seen on 2+ distinct calendar days ─────────────────────────── reg_mac_rows = query(""" SELECT src_mac, COUNT(DISTINCT date(received_at)) AS days_seen, MIN(received_at) AS first_seen, MAX(received_at) AS last_seen, MAX(rssi) AS best_rssi, COUNT(*) AS times_seen, COUNT(DISTINCT node_id) AS node_count FROM probe_events WHERE src_mac IS NOT NULL GROUP BY src_mac HAVING COUNT(DISTINCT date(received_at)) >= 2 ORDER BY days_seen DESC, times_seen DESC LIMIT 50 """) reg_macs = [] for r in reg_mac_rows: vendor, randomized = oui_lookup(r["src_mac"]) r["vendor"] = vendor r["randomized"] = randomized reg_macs.append(r) reg_networks = query(""" SELECT bssid, MAX(ssid) AS ssid, COUNT(DISTINCT date(received_at)) AS days_seen, MIN(received_at) AS first_seen, MAX(received_at) AS last_seen, MAX(rssi) AS best_rssi, MAX(channel) AS channel, MAX(encryption) AS encryption, COUNT(DISTINCT node_id) AS node_count FROM beacon_events WHERE bssid IS NOT NULL GROUP BY bssid HAVING COUNT(DISTINCT date(received_at)) >= 2 ORDER BY days_seen DESC, best_rssi DESC LIMIT 50 """) return { "present": present, "new_macs": new_macs, "new_networks": new_networks, "reg_macs": reg_macs, "reg_networks": reg_networks, } def build_node_detail(node_id: str) -> dict | None: rows = query(""" SELECT node_id, MIN(received_at) AS first_seen, MAX(received_at) AS last_seen, COUNT(*) AS total_events, COUNT(DISTINCT ssid) AS unique_ssids, COUNT(DISTINCT bssid) AS unique_bssids, AVG(rssi) AS avg_rssi, MIN(rssi) AS min_rssi, MAX(rssi) AS max_rssi FROM beacon_events WHERE node_id = ? GROUP BY node_id """, (node_id,)) if not rows: return None detail = rows[0] hb_rows = query(""" SELECT received_at AS last_heartbeat, uptime_ms, free_heap, wifi_rssi FROM heartbeat_events WHERE node_id = ? ORDER BY id DESC LIMIT 1 """, (node_id,)) hb = hb_rows[0] if hb_rows else {} detail["last_heartbeat"] = hb.get("last_heartbeat") detail["uptime_ms"] = hb.get("uptime_ms") detail["free_heap"] = hb.get("free_heap") detail["wifi_rssi"] = hb.get("wifi_rssi") if detail["last_heartbeat"]: detail["status"] = _node_status(detail["last_heartbeat"], threshold=30) else: detail["status"] = _node_status(detail["last_seen"], threshold=60) detail["avg_rssi"] = round(detail["avg_rssi"], 1) if detail["avg_rssi"] else None detail["events"] = build_events(limit=EVENT_CAP, node_id=node_id) return detail # ─── Routes ────────────────────────────────────────────────────────────────── def ensure_schema(): """Create tables if they don't exist. Runs once at startup.""" with get_conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS beacon_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL, node_id TEXT, node_ts INTEGER, ssid TEXT, bssid TEXT, rssi INTEGER, channel INTEGER, encryption TEXT, importance TEXT, confidence TEXT ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS probe_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL, node_id TEXT, node_ts INTEGER, src_mac TEXT, ssid TEXT, rssi INTEGER, importance TEXT ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS heartbeat_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL, node_id TEXT, node_ts INTEGER, uptime_ms INTEGER, free_heap INTEGER, wifi_rssi INTEGER ) """) conn.commit() app = FastAPI(title="ESP32 Recon Dashboard") ensure_schema() @app.get("/", response_class=HTMLResponse) async def index(): return FileResponse(HTML_PATH) @app.get("/static/dashboard.css") async def serve_css(): return FileResponse(CSS_PATH, media_type="text/css") @app.get("/static/dashboard.js") async def serve_js(): return FileResponse(JS_PATH, media_type="application/javascript") @app.get("/api/nodes") async def api_nodes(): return build_nodes() @app.get("/api/events") async def api_events(limit: int = EVENT_CAP): limit = min(limit, EVENT_CAP) return build_events(limit=limit) @app.get("/api/networks") async def api_networks(): return build_networks() @app.get("/api/clients") async def api_clients(): return build_clients() @app.get("/api/cross-node") async def api_cross_node(): return build_cross_node() @app.get("/api/presence") async def api_presence(): return build_presence() @app.get("/api/rssi-history") async def api_rssi_history(bssid: str, hours: int = 2): hours = min(max(hours, 1), 48) cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat(timespec="seconds") rows = query(""" SELECT node_id, received_at, rssi FROM beacon_events WHERE bssid = ? AND received_at >= ? ORDER BY received_at ASC """, (bssid, cutoff)) series: dict = {} for r in rows: nid = r["node_id"] if nid not in series: series[nid] = [] series[nid].append({"t": r["received_at"], "rssi": r["rssi"]}) return series @app.get("/api/heartbeats") async def api_heartbeats(): """Latest heartbeat per node.""" return query(""" SELECT node_id, received_at, uptime_ms, free_heap, wifi_rssi FROM heartbeat_events WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id) """) @app.get("/api/nodes/{node_id}") async def api_node_detail(node_id: str): detail = build_node_detail(node_id) if detail is None: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Node not found") return detail @app.get("/api/stream") async def api_stream(): """SSE stream — polls DB every 2 s, pushes new events to the browser.""" async def event_generator(): try: last_id = 0 # Start from the current latest event so we don't replay history on connect rows = query("SELECT MAX(id) AS max_id FROM beacon_events") if rows and rows[0]["max_id"]: last_id = rows[0]["max_id"] while True: await asyncio.sleep(2) new_rows = query(""" SELECT * FROM beacon_events WHERE id > ? ORDER BY id ASC """, (last_id,)) for row in new_rows: last_id = row["id"] payload = json.dumps(row) yield f"data: {payload}\n\n" except asyncio.CancelledError: pass # client disconnected or server shutting down — expected, not an error return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) # ─── Entry point ───────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run("dashboard:app", host="0.0.0.0", port=8080, reload=False)