ESP32 WiFi recon cluster

This commit is contained in:
bot
2026-04-03 14:38:38 +03:00
commit a73aae21ad
15 changed files with 237204 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
# ESP32 WiFi Recon Cluster
## What this is
A distributed WiFi reconnaissance system using ESP32-S3 nodes and an Orange Pi as coordinator.
Each ESP32 node scans the WiFi environment, builds structured events (not raw data), and ships them over UDP to a coordinator. The coordinator stores events to SQLite. A separate web dashboard reads the database and displays everything in real time via SSE.
This is a learning/research project covering distributed systems, event-driven architecture, sensor fusion, and detection engineering concepts.
---
## Hardware
- **Nodes:** ESP32-S3 N16R8 (2 active, scaling later)
- **Coordinator:** Orange Pi at `192.168.1.133`, runs 24/7 as systemd services
- **Dev machine:** This PC at `192.168.1.101` — coding and flashing only
- **Flashing:** Arduino CLI on this PC
### Active nodes
| node_id | MAC | Port |
|----------|-------------------|--------------|
| F68D6E30 | 44:1b:f6:8d:6e:30 | /dev/ttyACM0 |
| A1D6F190 | e0:72:a1:d6:f1:90 | /dev/ttyACM1 |
| A1D658D4 | e0:72:a1:d6:58:d4 | /dev/ttyACM2 |
---
## Network
- **ESP32 nodes connect to:** WiFi SSID `sandbox`
- **Coordinator IP:** `192.168.1.133` (Orange Pi, production)
- **UDP port:** `5005`
- **Dashboard port:** `8080`
---
## Architecture
```
[ESP32 nodes] --UDP 5005--> [udp_ingest.py] --write--> [events.db (SQLite)]
|
[Browser] <--HTTP/SSE 8080-- [dashboard.py] ------read--------+
```
The two coordinator processes are intentionally separate. The ingestor has one job: receive UDP, write to DB. The dashboard has one job: read DB, serve UI. They communicate only through the database. Each is independently restartable without affecting the other.
---
## Development workflow
**All coding is done on this PC.** The Orange Pi only runs the deployed files — never edit directly on it.
```
[This PC] → edit code → rsync → [Orange Pi] → restart services
```
### Sync to Orange Pi
```bash
bash /home/sandbox/Documents/projects/esp32_cluster/deploy.sh
```
This rsyncs `coordinator/` to `/opt/esp32_cluster/coordinator/` on the Orange Pi, excluding `events.db` (the Pi keeps its own database).
### Restart services after deploy
```bash
ssh root@192.168.1.133 'systemctl restart udp_ingest dashboard'
```
### View live logs on Orange Pi
```bash
# Ingestor (UDP events coming in)
ssh root@192.168.1.133 'journalctl -u udp_ingest -f'
# Dashboard
ssh root@192.168.1.133 'journalctl -u dashboard -f'
```
---
## Project structure
```
esp32_cluster/
├── firmware/
│ └── node/
│ ├── node.ino # ESP32 node firmware
│ └── config.h # WiFi creds, coordinator IP, scan/probe config
├── coordinator/
│ ├── udp_ingest.py # UDP listener — receives packets, validates, writes to SQLite
│ ├── dashboard.py # FastAPI app — reads SQLite, serves UI + SSE stream
│ ├── templates/
│ │ └── dashboard.html # Dashboard skeleton HTML
│ ├── static/
│ │ ├── dashboard.css # All dashboard styles
│ │ └── dashboard.js # All dashboard logic (vanilla JS)
│ ├── oui.txt # IEEE OUI database for vendor lookup (39k entries)
│ ├── udp_ingest.service # systemd service file for ingestor
│ ├── dashboard.service # systemd service file for dashboard
│ └── events.db # SQLite database — on Orange Pi only, not synced
├── deploy.sh # rsync script: this PC → Orange Pi
├── starting_plan.md # Original architecture design doc
└── README.md # This file
```
---
## Firmware
Each node:
- Connects to WiFi `sandbox`
- Scans for nearby APs every 15 seconds (active scan, includes hidden SSIDs)
- Captures probe request frames passively (promiscuous mode) between scans
- Sends a heartbeat packet every 10 seconds (uptime, free heap, WiFi RSSI to router)
- Sends beacon, probe, and heartbeat events as UDP JSON packets to the coordinator
- `node_id` is derived from bytes 25 of the ESP32's base MAC address
### Event formats
**Beacon event** (AP discovered):
```json
{
"node_id": "F68D6E30",
"ts": 12345,
"type": "beacon",
"conf": "high",
"imp": "normal",
"ssid": "NetworkName",
"bssid": "AA:BB:CC:DD:EE:FF",
"rssi": -65,
"ch": 6,
"enc": "WPA2"
}
```
**Probe event** (client device searching for a network):
```json
{
"node_id": "F68D6E30",
"ts": 12345,
"type": "probe",
"conf": "high",
"imp": "normal",
"src_mac": "AA:BB:CC:DD:EE:FF",
"ssid": "HomeNetwork",
"rssi": -55
}
```
**Heartbeat event** (node health, sent every 10 seconds):
```json
{
"node_id": "F68D6E30",
"ts": 12345,
"type": "heartbeat",
"uptime_ms": 123456,
"free_heap": 245000,
"wifi_rssi": -62
}
```
- `imp`: `high` if RSSI >= -50, `low` if <= -80, else `normal`
- `ssid` in probe events is the network the device is searching for — empty means wildcard (any network)
- Probe dedup: same MAC+SSID suppressed for 30 seconds to avoid flooding
- `wifi_rssi` in heartbeat is the node's own signal to the `sandbox` AP (not a scanned network)
- A node is considered **online** if a heartbeat arrived within the last 30 seconds; falls back to beacon within 60 seconds if no heartbeat has been received yet
### Flashing a node
Requirements: Arduino CLI v1.4.1, ESP32 core v3.3.7, FQBN `esp32:esp32:esp32s3`, user in `dialout` group.
```bash
# Compile
arduino-cli compile --fqbn esp32:esp32:esp32s3 /home/sandbox/Documents/projects/esp32_cluster/firmware/node/
# Flash — adjust port as needed (check with: ls /dev/ttyACM*)
arduino-cli upload --fqbn esp32:esp32:esp32s3 --port /dev/ttyACM0 \
/home/sandbox/Documents/projects/esp32_cluster/firmware/node/
```
The same binary works on every node — `node_id` is auto-derived from the MAC, no per-node config needed.
> Check group membership with `groups`. You should see `dialout`.
### config.h reference
| Constant | Default | Purpose |
|-------------------|------------------|-------------------------------------------|
| `WIFI_SSID` | `sandbox` | WiFi network to connect to |
| `COORDINATOR_IP` | `192.168.1.133` | Orange Pi address |
| `COORDINATOR_PORT`| `5005` | UDP port |
| `SCAN_INTERVAL_MS`| `15000` | How often to run a beacon scan (ms) |
| `PROBE_SNIFF` | `1` | Enable/disable probe sniffing (1/0) |
| `PROBE_DEDUP_SECS`| `30` | Suppress duplicate probe MAC+SSID (secs) |
| `PROBE_QUEUE_SIZE`| `32` | Max probe events buffered between scans |
---
## Orange Pi — running services
Services are managed by systemd and start automatically on boot.
```bash
# Status
ssh root@192.168.1.133 'systemctl status udp_ingest dashboard'
# Restart both
ssh root@192.168.1.133 'systemctl restart udp_ingest dashboard'
# Stop
ssh root@192.168.1.133 'systemctl stop udp_ingest dashboard'
```
Service files are in `coordinator/` and deployed to `/etc/systemd/system/` on the Orange Pi.
The coordinator files live at `/opt/esp32_cluster/coordinator/` on the Orange Pi.
---
## Dashboard
Open `http://192.168.1.133:8080` in your browser.
### Views
- **Feed** — live beacon event stream across all nodes, capped at 100 rows, newest on top
- **Networks** — deduplicated AP table (by BSSID): times seen, best/avg RSSI, channel, encryption, node count. Sortable by any column. Each row has a ▶ button to open the RSSI history chart for that network.
- **RSSI history chart** — opens from any Networks row. Shows signal strength over time per node (1h / 2h / 6h / 24h selectable). Each node gets its own coloured line. Useful for spotting interference patterns, seeing how signal fluctuates by time of day, and comparing which node consistently hears a given AP better. Updates live as new scans arrive.
- **Cross-node** — side-by-side per-node RSSI for every AP. Shows which node is physically closer to each network. Filter to multi-node only to focus on confirmed cross-node observations.
- **Clients** — probe request data: unique MACs, vendor (OUI lookup), what SSIDs they're searching for, signal, which nodes saw them.
- **Node detail** — click a node in the sidebar for stats: total events, unique SSIDs/BSSIDs, RSSI range, first/last seen, uptime, free heap, AP signal, last 100 events.
### Sidebar
Always visible: node list with online/offline status dot, last seen time, event count, and a compact heartbeat line (uptime · free heap · AP signal).
A node is considered **online** if a heartbeat was received within the last 30 seconds. Falls back to beacon timestamp (60s threshold) if no heartbeat has been received yet.
### Live updates
SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests.
---
## Observations and notes
### Hidden networks
Networks with a blank SSID but valid BSSID, signal, and channel are **hidden networks** — APs configured not to broadcast their name. They are not hidden from passive scanners. The encryption showing as `OPEN` is a firmware parsing artifact; the network itself may be encrypted. The BSSID OUI still identifies the manufacturer.
### Probe requests and MAC randomization
Modern phones and laptops (iOS since ~2020, Android since ~2021) randomize their MAC address for probe requests. A randomized MAC has bit 1 of the first byte set — the dashboard detects this and labels it "Randomized MAC" instead of doing a meaningless OUI lookup.
What probe data tells you:
- **Directed probes** (with SSID): the device has connected to that network in the past
- **Wildcard probes** (empty SSID): the device is searching for any available network
- Probe data maps **client devices**, not infrastructure — complementary to beacon scan data
### OUI lookup
The `oui.txt` file is the IEEE public OUI database (39,171 entries as of download). It maps the first 3 bytes of a real MAC to a manufacturer name. Used in the Clients tab. Refresh it occasionally by re-running `deploy.sh` after downloading a fresh copy from `https://standards-oui.ieee.org/oui/oui.txt`.
---
## Current status
- [x] Arduino CLI installed, ESP32 core configured
- [x] Node firmware: beacon scan + probe sniffing
- [x] Three ESP32-S3 nodes flashed and running
- [x] UDP ingestor (`udp_ingest.py`) — beacon + probe routing with field validation
- [x] Web dashboard: Feed, Networks, Cross-node, Clients, Node detail views
- [x] OUI vendor lookup in Clients view
- [x] Dashboard split into HTML / CSS / JS (no monolithic file)
- [x] Deployed to Orange Pi as systemd services (runs 24/7)
- [x] All three nodes sending to Orange Pi, confirmed live
- [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status
- [ ] Scan interval control from dashboard
---
## Python dependencies
```
fastapi
uvicorn
```
```bash
pip install fastapi uvicorn
```
Binary file not shown.
Binary file not shown.
+429
View File
@@ -0,0 +1,429 @@
#!/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
""")
# Attach probed SSIDs and OUI vendor info to each row
for r in rows:
ssid_rows = query("""
SELECT DISTINCT ssid FROM probe_events
WHERE src_mac = ? AND ssid IS NOT NULL AND ssid != ''
ORDER BY ssid
""", (r["src_mac"],))
r["probed_ssids"] = [s["ssid"] for s in ssid_rows]
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_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/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)
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=ESP32 Recon — Web Dashboard
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/esp32_cluster/coordinator
ExecStart=/usr/bin/python3 dashboard.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Binary file not shown.
+234729
View File
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0c110e;
--bg-panel: #111a14;
--bg-hover: #1a2b1e;
--border: #1e3024;
--green-dim: #2a5c38;
--green-mid: #3a8c50;
--green-hi: #4ec96a;
--green-live: #00ff6a;
--text: #c8d8cc;
--text-dim: #5a7a60;
--text-muted: #3a5040;
--orange: #e67e22;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
/* ── Layout ── */
#app {
display: grid;
grid-template-rows: 44px 42px 1fr;
grid-template-columns: 220px 1fr;
height: 100vh;
overflow: hidden;
}
/* ── Topbar ── */
#topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
#topbar .title {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.08em;
color: var(--green-hi);
text-transform: uppercase;
}
#topbar .sep { color: var(--text-muted); }
#topbar .build-info {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
#live-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-muted);
margin-left: auto;
transition: background 0.3s;
}
#live-dot.alive { background: var(--green-live); box-shadow: 0 0 6px var(--green-live); }
#live-label {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.06em;
}
#live-dot.alive + #live-label { color: var(--green-hi); }
/* ── Navbar ── */
#navbar {
grid-column: 1 / -1;
display: flex;
align-items: stretch;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 20px;
gap: 4px;
}
.tab-btn {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
padding: 0 18px;
cursor: pointer;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--text-dim); }
.tab-btn.active {
color: var(--green-hi);
border-bottom-color: var(--green-hi);
}
/* ── Sidebar ── */
#sidebar {
border-right: 1px solid var(--border);
background: var(--bg-panel);
overflow-y: auto;
padding: 12px 0;
}
.sidebar-section {
padding: 0 14px;
margin-bottom: 20px;
}
.sidebar-label {
font-size: 9px;
letter-spacing: 0.12em;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
color: var(--text-dim);
font-size: 11px;
padding: 2px 0;
}
.stat-row span:last-child { color: var(--text); }
#node-list { padding: 0; }
.node-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.node-item:hover { background: var(--bg-hover); }
.node-item.active {
background: var(--bg-hover);
border-left-color: var(--green-hi);
}
.node-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.node-dot.online { background: var(--green-live); box-shadow: 0 0 4px var(--green-live); }
.node-dot.offline { background: var(--text-muted); }
.node-info { flex: 1; min-width: 0; }
.node-name {
font-size: 12px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-meta {
font-size: 10px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-count {
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
}
.node-hb {
font-size: 9px;
color: var(--text-muted);
letter-spacing: 0.03em;
}
/* ── Main content ── */
#main {
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.view { display: none; flex-direction: column; gap: 16px; }
.view.active { display: flex; }
/* ── Back button ── */
#back-btn, .back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
background: none;
border: 1px solid var(--border);
padding: 5px 12px;
cursor: pointer;
letter-spacing: 0.05em;
align-self: flex-start;
transition: color 0.15s, border-color 0.15s;
}
#back-btn:hover, .back-btn:hover { color: var(--green-hi); border-color: var(--green-dim); }
/* ── RSSI chart ── */
#rssi-chart {
width: 100%;
display: block;
background: var(--bg-panel);
border: 1px solid var(--border);
}
#chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 10px;
color: var(--text-dim);
margin-bottom: 8px;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-swatch { width: 24px; height: 2px; flex-shrink: 0; }
.range-btns { display: flex; gap: 4px; }
.range-btn {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: 0.08em;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 3px 10px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.range-btn:hover { color: var(--text-dim); border-color: var(--green-dim); }
.range-btn.active { color: var(--green-hi); border-color: var(--green-dim); }
.chart-btn {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 2px 8px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.chart-btn:hover { color: var(--green-hi); border-color: var(--green-dim); }
/* ── Section header ── */
.section-header {
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.section-title {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
.section-count { font-size: 10px; color: var(--text-muted); }
/* ── Node detail stats ── */
#detail-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--border);
padding: 12px 14px;
}
.stat-card .label {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-card .value {
font-size: 16px;
font-weight: 500;
color: var(--green-hi);
}
.stat-card .sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
/* ── Table ── */
.tbl-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead th {
text-align: left;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-weight: 400;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }
tbody tr:hover { background: var(--bg-hover); }
tbody tr.new-row { animation: fadeIn 0.4s ease; }
@keyframes fadeIn {
from { background: var(--green-dim); }
to { background: transparent; }
}
td {
padding: 6px 10px;
color: var(--text);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
td.dim { color: var(--text-dim); }
td.muted { color: var(--text-muted); }
/* ── Badges ── */
.badge { display: inline-block; padding: 1px 6px; font-size: 10px; letter-spacing: 0.04em; }
.badge.high { color: var(--green-live); border: 1px solid var(--green-dim); }
.badge.normal { color: var(--text-dim); border: 1px solid var(--border); }
.badge.low { color: var(--text-muted); border: 1px solid var(--text-muted); }
/* ── RSSI colours ── */
.rssi-hi { color: var(--green-hi); }
.rssi-mid { color: var(--text); }
.rssi-low { color: var(--text-muted); }
/* ── Sortable headers ── */
thead th.sortable { cursor: pointer; user-select: none; }
thead th.sortable:hover { color: var(--text-dim); }
thead th.sort-active { color: var(--green-hi); }
thead th.sort-active::after { content: ' ' attr(data-arrow); }
/* ── Enc / node colours ── */
.enc-open { color: var(--orange); }
.nodes-multi { color: var(--green-hi); }
/* ── Cross-node cells ── */
.cn-best { color: var(--green-hi); font-weight: 500; }
.cn-none { color: var(--text-muted); font-style: italic; }
/* ── Empty state ── */
.empty {
padding: 40px 0;
text-align: center;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.06em;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--green-dim); }
+537
View File
@@ -0,0 +1,537 @@
// ── State ────────────────────────────────────────────────────────────────────
const MAX_ROWS = 100;
let generalEvents = [];
let currentNode = null;
let currentView = 'feed';
let nodeData = {};
let networksData = [];
let netSort = { col: 'times_seen', dir: 'desc' };
let crossNodeData = { node_ids: [], networks: [] };
let clientsData = [];
let chartBssid = null;
let chartHours = 2;
const NODE_COLORS = ['#4ec96a', '#e67e22', '#7eb3ff', '#c678dd'];
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmt(val, fallback = '—') {
return (val !== null && val !== undefined && val !== '') ? val : fallback;
}
function rssiClass(rssi) {
if (rssi >= -50) return 'rssi-hi';
if (rssi >= -80) return 'rssi-mid';
return 'rssi-low';
}
function impBadge(imp) {
const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal';
return `<span class="badge ${cls}">${fmt(imp)}</span>`;
}
function shortTime(iso) {
if (!iso) return '—';
return iso.replace('T', ' ');
}
function fmtUptime(ms) {
if (ms == null) return '—';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0) return `${d}d ${h % 24}h`;
if (h > 0) return `${h}h ${m % 60}m`;
if (m > 0) return `${m}m`;
return `${s}s`;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function renderSidebar(nodes) {
const online = nodes.filter(n => n.status === 'online').length;
document.getElementById('sum-nodes').textContent = nodes.length;
document.getElementById('sum-online').textContent = online;
document.getElementById('node-list').innerHTML = nodes.map(n => {
const hbParts = [];
if (n.uptime_ms != null) hbParts.push(`${fmtUptime(n.uptime_ms)}`);
if (n.free_heap != null) hbParts.push(`${Math.round(n.free_heap / 1024)}K`);
if (n.wifi_rssi != null) hbParts.push(`${n.wifi_rssi}dBm`);
const hbLine = hbParts.join(' · ');
return `
<div class="node-item ${currentNode === n.node_id ? 'active' : ''}"
data-node-id="${n.node_id}"
onclick="showDetail('${n.node_id}')">
<div class="node-dot ${n.status}"></div>
<div class="node-info">
<div class="node-name">${n.node_id}</div>
<div class="node-meta">${shortTime(n.last_seen)}</div>
${hbLine ? `<div class="node-hb">${hbLine}</div>` : ''}
</div>
<div class="node-count">${n.total_events}</div>
</div>`;
}).join('');
}
// ── Feed view ─────────────────────────────────────────────────────────────────
function renderGeneralTable(animate = false) {
const tbody = document.getElementById('general-tbody');
const empty = document.getElementById('general-empty');
document.getElementById('general-count').textContent = `${generalEvents.length} events`;
if (!generalEvents.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
if (animate) {
const tr = document.createElement('tr');
tr.className = 'new-row';
tr.innerHTML = generalRowHTML(generalEvents[0]);
tbody.prepend(tr);
while (tbody.rows.length > MAX_ROWS) tbody.deleteRow(tbody.rows.length - 1);
} else {
tbody.innerHTML = generalEvents.map(generalRowHTML).join('');
}
}
function generalRowHTML(r) {
return `<tr>
<td class="dim">${shortTime(r.received_at)}</td>
<td>${fmt(r.node_id)}</td>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
<td class="muted">${fmt(r.channel)}</td>
<td class="muted">${fmt(r.encryption)}</td>
<td>${impBadge(r.importance)}</td>
</tr>`;
}
// ── Node detail view ──────────────────────────────────────────────────────────
function renderDetail(d) {
document.getElementById('detail-node-id').textContent = d.node_id;
document.getElementById('ds-status').textContent = d.status.toUpperCase();
document.getElementById('ds-status').style.color = d.status === 'online' ? 'var(--green-live)' : 'var(--text-muted)';
document.getElementById('ds-total').textContent = d.total_events;
document.getElementById('ds-ssids').textContent = d.unique_ssids;
document.getElementById('ds-bssids').textContent = d.unique_bssids;
document.getElementById('ds-rssi-avg').textContent = d.avg_rssi !== null ? `${d.avg_rssi} dBm` : '—';
document.getElementById('ds-rssi-range').textContent = d.min_rssi !== null ? `${d.min_rssi} / ${d.max_rssi} dBm` : '';
document.getElementById('ds-first').textContent = shortTime(d.first_seen);
document.getElementById('ds-last').textContent = shortTime(d.last_seen);
document.getElementById('ds-uptime').textContent = d.uptime_ms != null ? fmtUptime(d.uptime_ms) : '—';
document.getElementById('ds-heap').textContent = d.free_heap != null ? Math.round(d.free_heap / 1024) + ' KB' : '—';
document.getElementById('ds-ap-rssi').textContent = d.wifi_rssi != null ? d.wifi_rssi + ' dBm' : '—';
document.getElementById('detail-count').textContent = `${d.events.length} events (last ${MAX_ROWS})`;
document.getElementById('detail-tbody').innerHTML = d.events.map(r => `
<tr>
<td class="dim">${shortTime(r.received_at)}</td>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
<td class="${rssiClass(r.rssi)}">${fmt(r.rssi)} dBm</td>
<td class="muted">${fmt(r.channel)}</td>
<td class="muted">${fmt(r.encryption)}</td>
<td>${impBadge(r.importance)}</td>
<td class="muted">${fmt(r.confidence)}</td>
</tr>`).join('');
}
// ── Networks view ─────────────────────────────────────────────────────────────
async function fetchNetworks() {
networksData = await fetch('/api/networks').then(r => r.json());
renderNetworksTable();
}
function renderNetworksTable() {
const tbody = document.getElementById('networks-tbody');
const empty = document.getElementById('networks-empty');
const count = document.getElementById('networks-count');
if (!networksData.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
count.textContent = '';
return;
}
empty.style.display = 'none';
const sorted = [...networksData].sort((a, b) => {
let av = a[netSort.col], bv = b[netSort.col];
if (av == null) av = netSort.dir === 'asc' ? Infinity : -Infinity;
if (bv == null) bv = netSort.dir === 'asc' ? Infinity : -Infinity;
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av < bv) return netSort.dir === 'asc' ? -1 : 1;
if (av > bv) return netSort.dir === 'asc' ? 1 : -1;
return 0;
});
count.textContent = `${sorted.length} unique networks`;
tbody.innerHTML = sorted.map(r => {
const encCls = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.ssid, '<hidden>')}</td>
<td class="dim">${fmt(r.bssid)}</td>
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
<td class="muted">${fmt(r.channel)}</td>
<td class="${encCls}">${r.encryption || 'OPEN'}</td>
<td>${fmt(r.times_seen)}</td>
<td class="${nodeCls}">${fmt(r.node_count)}</td>
<td class="dim">${shortTime(r.last_seen)}</td>
<td><button class="chart-btn" onclick="showNetworkChart('${r.bssid}')">&#9654;</button></td>
</tr>`;
}).join('');
}
function initNetworkSort() {
document.querySelectorAll('#view-networks thead th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (netSort.col === col) {
netSort.dir = netSort.dir === 'desc' ? 'asc' : 'desc';
} else {
netSort.col = col;
netSort.dir = ['ssid', 'bssid', 'encryption'].includes(col) ? 'asc' : 'desc';
}
document.querySelectorAll('#view-networks thead th').forEach(h => {
h.classList.remove('sort-active');
h.dataset.arrow = '';
});
th.classList.add('sort-active');
th.dataset.arrow = netSort.dir === 'desc' ? '↓' : '↑';
renderNetworksTable();
});
});
}
// ── Cross-node view ───────────────────────────────────────────────────────────
async function fetchCrossNode() {
crossNodeData = await fetch('/api/cross-node').then(r => r.json());
renderCrossNodeTable();
}
function renderCrossNodeTable() {
const thead = document.getElementById('crossnode-thead');
const tbody = document.getElementById('crossnode-tbody');
const empty = document.getElementById('crossnode-empty');
const count = document.getElementById('crossnode-count');
const filter = document.getElementById('crossnode-filter').checked;
const nodeIds = crossNodeData.node_ids || [];
let networks = crossNodeData.networks || [];
if (filter) networks = networks.filter(n => n.node_count > 1);
if (!networks.length) {
thead.innerHTML = tbody.innerHTML = '';
empty.style.display = 'block';
count.textContent = '';
return;
}
empty.style.display = 'none';
count.textContent = `${networks.length} networks`;
thead.innerHTML = `
<tr>
<th>SSID</th><th>BSSID</th><th>Ch</th><th>Enc</th>
${nodeIds.map(id => `<th colspan="2" style="text-align:center;border-left:1px solid var(--border)">${id}</th>`).join('')}
<th>Nodes</th>
</tr>
<tr>
<th></th><th></th><th></th><th></th>
${nodeIds.map(() => `
<th style="border-left:1px solid var(--border);font-size:8px">best</th>
<th style="font-size:8px">avg</th>
`).join('')}
<th></th>
</tr>`;
tbody.innerHTML = networks.map(net => {
const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi);
const globalBest = seenRssis.length ? Math.max(...seenRssis) : null;
const encCls = (!net.encryption || net.encryption === 'OPEN' || net.encryption === 'None') ? 'enc-open' : 'muted';
const nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted';
const nodeCells = nodeIds.map(id => {
if (!net.nodes[id]) return `<td class="cn-none" style="border-left:1px solid var(--border)">—</td><td class="cn-none">—</td>`;
const n = net.nodes[id];
const isBest = n.best_rssi === globalBest && net.node_count > 1;
const bestCls = isBest ? 'cn-best' : rssiClass(n.best_rssi);
return `<td class="${bestCls}" style="border-left:1px solid var(--border)">${n.best_rssi} dBm</td>
<td class="${isBest ? 'cn-best' : 'dim'}">${n.avg_rssi} dBm</td>`;
}).join('');
return `<tr>
<td>${fmt(net.ssid, '<hidden>')}</td>
<td class="dim">${fmt(net.bssid)}</td>
<td class="muted">${fmt(net.channel)}</td>
<td class="${encCls}">${net.encryption || 'OPEN'}</td>
${nodeCells}
<td class="${nodeCls}">${net.node_count} / ${nodeIds.length}</td>
</tr>`;
}).join('');
}
// ── Clients view ──────────────────────────────────────────────────────────────
async function fetchClients() {
clientsData = await fetch('/api/clients').then(r => r.json());
renderClientsTable();
}
function renderClientsTable() {
const tbody = document.getElementById('clients-tbody');
const empty = document.getElementById('clients-empty');
const count = document.getElementById('clients-count');
if (!clientsData.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
count.textContent = '';
return;
}
empty.style.display = 'none';
count.textContent = `${clientsData.length} unique MACs`;
tbody.innerHTML = clientsData.map(r => {
const probing = r.probed_ssids.length
? r.probed_ssids.map(s => `<span style="color:var(--text-dim)">${s}</span>`).join(', ')
: '<span style="color:var(--text-muted)">wildcard only</span>';
const vendorCls = r.randomized ? 'muted' : 'dim';
const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
return `<tr>
<td>${fmt(r.src_mac)}</td>
<td class="${vendorCls}">${r.vendor || '—'}</td>
<td style="max-width:260px;white-space:normal;line-height:1.8">${probing}</td>
<td class="${rssiClass(r.best_rssi)}">${fmt(r.best_rssi)} dBm</td>
<td class="${rssiClass(r.avg_rssi)}">${fmt(r.avg_rssi)} dBm</td>
<td class="${nodeCls}">${fmt(r.node_count)}</td>
<td>${fmt(r.times_seen)}</td>
<td class="dim">${shortTime(r.last_seen)}</td>
</tr>`;
}).join('');
}
// ── RSSI chart ────────────────────────────────────────────────────────────────
async function showNetworkChart(bssid) {
chartBssid = bssid;
const net = networksData.find(n => n.bssid === bssid);
document.getElementById('chart-title').textContent = fmt(net?.ssid, '<hidden>');
document.getElementById('chart-subtitle').textContent = bssid;
setView('network-chart');
// requestAnimationFrame ensures the view is laid out before we read canvas.offsetWidth
requestAnimationFrame(() => loadAndRenderChart());
}
async function setChartRange(hours) {
chartHours = hours;
document.querySelectorAll('.range-btn').forEach(b => {
b.classList.toggle('active', parseInt(b.dataset.hours) === hours);
});
await loadAndRenderChart();
}
async function loadAndRenderChart() {
const data = await fetch(`/api/rssi-history?bssid=${encodeURIComponent(chartBssid)}&hours=${chartHours}`).then(r => r.json());
renderRssiChart(data);
}
function renderRssiChart(series) {
const canvas = document.getElementById('rssi-chart');
const empty = document.getElementById('chart-empty');
const legend = document.getElementById('chart-legend');
const nodeIds = Object.keys(series);
const allPts = nodeIds.flatMap(id => series[id]);
if (!allPts.length) {
canvas.style.display = 'none';
empty.style.display = 'block';
legend.innerHTML = '';
return;
}
canvas.style.display = 'block';
empty.style.display = 'none';
const W = canvas.offsetWidth;
const H = canvas.height;
canvas.width = W;
const ctx = canvas.getContext('2d');
const pad = { top: 20, right: 16, bottom: 36, left: 52 };
const pw = W - pad.left - pad.right;
const ph = H - pad.top - pad.bottom;
const now = Date.now();
const tStart = now - chartHours * 3600000;
const rssis = allPts.map(p => p.rssi);
const minR = Math.floor((Math.min(...rssis) - 5) / 5) * 5;
const maxR = Math.ceil ((Math.max(...rssis) + 5) / 5) * 5;
const rng = maxR - minR || 1;
const xMap = t => pad.left + (new Date(t + 'Z').getTime() - tStart) / (now - tStart) * pw;
const yMap = r => pad.top + (1 - (r - minR) / rng) * ph;
ctx.clearRect(0, 0, W, H);
ctx.font = '10px IBM Plex Mono';
// Horizontal gridlines + Y labels
const step = rng <= 20 ? 5 : rng <= 50 ? 10 : 20;
for (let r = minR; r <= maxR; r += step) {
const y = yMap(r);
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + pw, y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#3a5040'; ctx.textAlign = 'right';
ctx.fillText(`${r}`, pad.left - 6, y + 3);
}
// X axis time ticks
const ticks = Math.min(6, Math.floor(pw / 80));
ctx.fillStyle = '#3a5040'; ctx.textAlign = 'center';
for (let i = 0; i <= ticks; i++) {
const t = tStart + (i / ticks) * (now - tStart);
const x = pad.left + (i / ticks) * pw;
ctx.fillText(new Date(t).toTimeString().slice(0, 5), x, H - 8);
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, pad.top + ph); ctx.lineTo(x, pad.top + ph + 4); ctx.stroke();
}
// Plot border
ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, pad.top + ph);
ctx.lineTo(pad.left + pw, pad.top + ph);
ctx.stroke();
// Series
nodeIds.forEach((nid, i) => {
const pts = series[nid];
const color = NODE_COLORS[i % NODE_COLORS.length];
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath();
pts.forEach((p, j) => {
const x = xMap(p.t), y = yMap(p.rssi);
j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
ctx.fillStyle = color;
pts.forEach(p => {
ctx.beginPath();
ctx.arc(xMap(p.t), yMap(p.rssi), 2.5, 0, Math.PI * 2);
ctx.fill();
});
});
// Legend
legend.innerHTML = nodeIds.map((nid, i) => `
<div class="legend-item">
<div class="legend-swatch" style="background:${NODE_COLORS[i % NODE_COLORS.length]}"></div>
<span>${nid}</span>
</div>`).join('');
}
// ── View switching ────────────────────────────────────────────────────────────
function setView(view) {
currentView = view;
document.getElementById('view-general').classList.toggle('active', view === 'feed');
document.getElementById('view-networks').classList.toggle('active', view === 'networks');
document.getElementById('view-network-chart').classList.toggle('active', view === 'network-chart');
document.getElementById('view-crossnode').classList.toggle('active', view === 'crossnode');
document.getElementById('view-clients').classList.toggle('active', view === 'clients');
document.getElementById('view-detail').classList.toggle('active', view === 'detail');
document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail');
document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart');
document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode');
document.getElementById('tab-clients').classList.toggle('active', view === 'clients');
}
function showFeed() {
currentNode = null;
setView('feed');
document.querySelectorAll('.node-item').forEach(el => el.classList.remove('active'));
}
function showGeneral() { showFeed(); }
function showNetworks() { setView('networks'); fetchNetworks(); }
async function showCrossNode() { setView('crossnode'); await fetchCrossNode(); }
async function showClients() { setView('clients'); await fetchClients(); }
async function showDetail(node_id) {
currentNode = node_id;
setView('detail');
document.querySelectorAll('.node-item').forEach(el => {
el.classList.toggle('active', el.dataset.nodeId === node_id);
});
const data = await fetch(`/api/nodes/${node_id}`).then(r => r.json());
renderDetail(data);
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function init() {
const [nodes, events] = await Promise.all([
fetch('/api/nodes').then(r => r.json()),
fetch(`/api/events?limit=${MAX_ROWS}`).then(r => r.json()),
]);
generalEvents = events;
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
document.getElementById('sum-events').textContent = generalEvents.length;
renderSidebar(nodes);
renderGeneralTable(false);
}
// ── SSE ───────────────────────────────────────────────────────────────────────
function startSSE() {
const es = new EventSource('/api/stream');
const dot = document.getElementById('live-dot');
let sseTimer = null;
es.onopen = () => dot.classList.add('alive');
es.onmessage = (e) => {
dot.classList.remove('alive');
requestAnimationFrame(() => dot.classList.add('alive'));
// Update the feed array immediately — this is just JS, no API call needed
const ev = JSON.parse(e.data);
generalEvents.unshift(ev);
if (generalEvents.length > MAX_ROWS) generalEvents.pop();
document.getElementById('sum-events').textContent = generalEvents.length;
if (currentView === 'feed') renderGeneralTable(true);
// Debounce all API-backed refreshes: a scan fires 20+ events in rapid
// succession — only act on the trailing edge, at most once per 2 seconds.
if (sseTimer) return;
sseTimer = setTimeout(async () => {
sseTimer = null;
if (currentView === 'networks') await fetchNetworks();
else if (currentView === 'network-chart') await loadAndRenderChart();
else if (currentView === 'crossnode') await fetchCrossNode();
const nodes = await fetch('/api/nodes').then(r => r.json());
nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n]));
renderSidebar(nodes);
if (currentView === 'detail' && currentNode) {
const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json());
renderDetail(data);
}
}, 2000);
};
es.onerror = () => { dot.classList.remove('alive'); setTimeout(startSSE, 3000); };
}
// ── Boot ──────────────────────────────────────────────────────────────────────
initNetworkSort();
init().then(startSSE);
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32 Recon</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/dashboard.css" />
</head>
<body>
<div id="app">
<!-- Topbar -->
<div id="topbar">
<span class="title">ESP32 Recon</span>
<span class="sep">/</span>
<span class="build-info">WiFi Recon Cluster</span>
<div id="live-dot"></div>
<span id="live-label">LIVE</span>
</div>
<!-- Navbar -->
<div id="navbar">
<button class="tab-btn active" id="tab-feed" onclick="showFeed()">Feed</button>
<button class="tab-btn" id="tab-networks" onclick="showNetworks()">Networks</button>
<button class="tab-btn" id="tab-crossnode" onclick="showCrossNode()">Cross-node</button>
<button class="tab-btn" id="tab-clients" onclick="showClients()">Clients</button>
</div>
<!-- Sidebar -->
<div id="sidebar">
<div class="sidebar-section">
<div class="sidebar-label">Summary</div>
<div class="stat-row"><span>nodes</span><span id="sum-nodes"></span></div>
<div class="stat-row"><span>online</span><span id="sum-online"></span></div>
<div class="stat-row"><span>events (session)</span><span id="sum-events"></span></div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Nodes</div>
<div id="node-list"></div>
</div>
</div>
<!-- Main -->
<div id="main">
<!-- Feed view -->
<div id="view-general" class="view active">
<div class="section-header">
<span class="section-title">Live Feed</span>
<span class="section-count" id="general-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Node</th>
<th>SSID</th>
<th>BSSID</th>
<th>RSSI</th>
<th>Ch</th>
<th>Enc</th>
<th>Imp</th>
</tr>
</thead>
<tbody id="general-tbody"></tbody>
</table>
<div class="empty" id="general-empty" style="display:none">No events yet.</div>
</div>
</div>
<!-- Node detail view -->
<div id="view-detail" class="view">
<button id="back-btn" onclick="showGeneral()">&#8592; All nodes</button>
<div class="section-header">
<span class="section-title">Node</span>
<span class="section-count" id="detail-node-id"></span>
</div>
<div id="detail-stats-grid">
<div class="stat-card"><div class="label">Status</div><div class="value" id="ds-status"></div></div>
<div class="stat-card"><div class="label">Total Events</div><div class="value" id="ds-total"></div></div>
<div class="stat-card"><div class="label">Unique SSIDs</div><div class="value" id="ds-ssids"></div></div>
<div class="stat-card"><div class="label">Unique BSSIDs</div><div class="value" id="ds-bssids"></div></div>
<div class="stat-card"><div class="label">Avg RSSI</div><div class="value" id="ds-rssi-avg"></div><div class="sub" id="ds-rssi-range"></div></div>
<div class="stat-card"><div class="label">First Seen</div><div class="value" style="font-size:12px" id="ds-first"></div></div>
<div class="stat-card"><div class="label">Last Seen</div><div class="value" style="font-size:12px" id="ds-last"></div></div>
<div class="stat-card"><div class="label">Uptime</div><div class="value" id="ds-uptime"></div></div>
<div class="stat-card"><div class="label">Free Heap</div><div class="value" id="ds-heap"></div></div>
<div class="stat-card"><div class="label">AP Signal</div><div class="value" id="ds-ap-rssi"></div><div class="sub">node → router</div></div>
</div>
<div class="section-header" style="margin-top:4px">
<span class="section-title">Recent Events</span>
<span class="section-count" id="detail-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>SSID</th>
<th>BSSID</th>
<th>RSSI</th>
<th>Ch</th>
<th>Enc</th>
<th>Imp</th>
<th>Conf</th>
</tr>
</thead>
<tbody id="detail-tbody"></tbody>
</table>
</div>
</div>
<!-- Clients view -->
<div id="view-clients" class="view">
<div class="section-header">
<span class="section-title">Client Devices</span>
<span class="section-count" id="clients-count"></span>
<span style="margin-left:auto; font-size:10px; color:var(--text-muted)">
note: modern devices use randomised MACs — one physical device may appear as multiple entries
</span>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>MAC</th>
<th>Vendor</th>
<th>Probing for</th>
<th>Best RSSI</th>
<th>Avg RSSI</th>
<th>Nodes</th>
<th>Times Seen</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="clients-tbody"></tbody>
</table>
<div class="empty" id="clients-empty" style="display:none">No probe events yet. Nodes need to be flashed with probe sniffing enabled.</div>
</div>
</div>
<!-- Cross-node view -->
<div id="view-crossnode" class="view">
<div class="section-header">
<span class="section-title">Cross-node RSSI</span>
<span class="section-count" id="crossnode-count"></span>
<label style="margin-left:auto; font-size:10px; color:var(--text-dim); display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" id="crossnode-filter" onchange="renderCrossNodeTable()" style="accent-color:var(--green-hi)">
multi-node only
</label>
</div>
<div class="tbl-wrap">
<table id="crossnode-table">
<thead id="crossnode-thead"></thead>
<tbody id="crossnode-tbody"></tbody>
</table>
<div class="empty" id="crossnode-empty" style="display:none">No data yet.</div>
</div>
</div>
<!-- Network RSSI chart view -->
<div id="view-network-chart" class="view">
<button class="back-btn" onclick="showNetworks()">&#8592; Networks</button>
<div class="section-header">
<span class="section-title" id="chart-title">RSSI History</span>
<span class="section-count" id="chart-subtitle"></span>
<div class="range-btns" style="margin-left:auto">
<button class="range-btn" data-hours="1" onclick="setChartRange(1)">1h</button>
<button class="range-btn active" data-hours="2" onclick="setChartRange(2)">2h</button>
<button class="range-btn" data-hours="6" onclick="setChartRange(6)">6h</button>
<button class="range-btn" data-hours="24" onclick="setChartRange(24)">24h</button>
</div>
</div>
<div id="chart-legend"></div>
<canvas id="rssi-chart" height="240"></canvas>
<div class="empty" id="chart-empty" style="display:none">No data for this time range.</div>
</div>
<!-- Networks view -->
<div id="view-networks" class="view">
<div class="section-header">
<span class="section-title">Networks</span>
<span class="section-count" id="networks-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th class="sortable" data-col="ssid" data-arrow="">SSID</th>
<th class="sortable" data-col="bssid" data-arrow="">BSSID</th>
<th class="sortable" data-col="best_rssi" data-arrow="">Best RSSI</th>
<th class="sortable" data-col="avg_rssi" data-arrow="">Avg RSSI</th>
<th class="sortable" data-col="channel" data-arrow="">Ch</th>
<th class="sortable" data-col="encryption" data-arrow="">Enc</th>
<th class="sortable sort-active" data-col="times_seen" data-arrow="↓">Times Seen</th>
<th class="sortable" data-col="node_count" data-arrow="">Nodes</th>
<th class="sortable" data-col="last_seen" data-arrow="">Last Seen</th>
<th></th>
</tr>
</thead>
<tbody id="networks-tbody"></tbody>
</table>
<div class="empty" id="networks-empty" style="display:none">No networks seen yet.</div>
</div>
</div>
</div><!-- /#main -->
</div><!-- /#app -->
<script src="/static/dashboard.js"></script>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
WiFi Recon Coordinator — UDP Ingestor
Receives beacon and probe events from ESP32 nodes over UDP, writes to SQLite.
This process has one job: ingest. Dashboard is separate.
"""
import socket
import json
import sqlite3
import datetime
import os
UDP_IP = "0.0.0.0"
UDP_PORT = 5005
DB_PATH = os.path.join(os.path.dirname(__file__), "events.db")
# ─── Database ────────────────────────────────────────────────────────────────
def init_db(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, check_same_thread=False)
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()
return conn
def store_beacon(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO beacon_events
(received_at, node_id, node_ts, ssid, bssid, rssi, channel, encryption, importance, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
ev.get("ts"),
ev.get("ssid"),
ev.get("bssid"),
ev.get("rssi"),
ev.get("ch"),
ev.get("enc"),
ev.get("imp"),
ev.get("conf"),
))
conn.commit()
def store_heartbeat(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO heartbeat_events
(received_at, node_id, node_ts, uptime_ms, free_heap, wifi_rssi)
VALUES (?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
ev.get("ts"),
ev.get("uptime_ms"),
ev.get("free_heap"),
ev.get("wifi_rssi"),
))
conn.commit()
def store_probe(conn: sqlite3.Connection, ev: dict, received_at: str):
conn.execute("""
INSERT INTO probe_events
(received_at, node_id, node_ts, src_mac, ssid, rssi, importance)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
received_at,
ev.get("node_id"),
ev.get("ts"),
ev.get("src_mac"),
ev.get("ssid"),
ev.get("rssi"),
ev.get("imp"),
))
conn.commit()
# ─── Validation ──────────────────────────────────────────────────────────────
import re
_MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
def _check_mac(val: str) -> bool:
return isinstance(val, str) and bool(_MAC_RE.match(val))
def _check_rssi(val) -> bool:
return isinstance(val, (int, float)) and -100 <= val <= 0
def validate_beacon(ev: dict) -> str | None:
"""Returns an error string if invalid, else None."""
for field in ("node_id", "bssid", "rssi", "ch"):
if ev.get(field) is None:
return f"missing field '{field}'"
if not _check_rssi(ev["rssi"]):
return f"rssi out of range: {ev['rssi']}"
if not isinstance(ev["ch"], int) or not (1 <= ev["ch"] <= 14):
return f"channel out of range: {ev['ch']}"
if not _check_mac(ev["bssid"]):
return f"bad bssid format: {ev['bssid']}"
return None
def validate_heartbeat(ev: dict) -> str | None:
for field in ("node_id", "uptime_ms", "free_heap", "wifi_rssi"):
if ev.get(field) is None:
return f"missing field '{field}'"
if not isinstance(ev["uptime_ms"], int) or ev["uptime_ms"] < 0:
return f"invalid uptime_ms: {ev['uptime_ms']}"
if not isinstance(ev["free_heap"], int) or ev["free_heap"] < 0:
return f"invalid free_heap: {ev['free_heap']}"
if not _check_rssi(ev["wifi_rssi"]):
return f"wifi_rssi out of range: {ev['wifi_rssi']}"
return None
def validate_probe(ev: dict) -> str | None:
"""Returns an error string if invalid, else None."""
for field in ("node_id", "src_mac", "rssi"):
if ev.get(field) is None:
return f"missing field '{field}'"
if not _check_rssi(ev["rssi"]):
return f"rssi out of range: {ev['rssi']}"
if not _check_mac(ev["src_mac"]):
return f"bad src_mac format: {ev['src_mac']}"
return None
# ─── Packet handling ─────────────────────────────────────────────────────────
IMP_COLOR = {
"high": "\033[92m",
"normal": "\033[0m",
"low": "\033[90m",
}
RESET = "\033[0m"
CYAN = "\033[96m"
YELLOW = "\033[93m"
def handle_packet(data: bytes, addr: tuple, conn: sqlite3.Connection):
try:
ev = json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
print(f"[WARN] Bad packet from {addr[0]}: {data[:80]}")
return
received_at = datetime.datetime.utcnow().isoformat(timespec="seconds")
pkt_type = ev.get("type", "beacon")
if pkt_type == "heartbeat":
err = validate_heartbeat(ev)
if err:
print(f"[DROP] {addr[0]} heartbeat — {err}")
return
store_heartbeat(conn, ev, received_at)
node = ev.get("node_id", "?")
uptime = ev.get("uptime_ms", 0) // 1000
heap = ev.get("free_heap", 0) // 1024
wrssi = ev.get("wifi_rssi", 0)
print(f"{YELLOW}[{received_at}] {node} HB ↑{uptime}s {heap}K heap AP:{wrssi}dBm{RESET}")
elif pkt_type == "probe":
err = validate_probe(ev)
if err:
print(f"[DROP] {addr[0]} probe — {err} raw={data[:120]}")
return
store_probe(conn, ev, received_at)
node = ev.get("node_id", "?")
src_mac = ev.get("src_mac", "?")
ssid = ev.get("ssid") or "<wildcard>"
rssi = ev.get("rssi", 0)
print(f"{CYAN}[{received_at}] {node} PROBE {src_mac}\"{ssid}\" {rssi:>4}dBm{RESET}")
else:
err = validate_beacon(ev)
if err:
print(f"[DROP] {addr[0]} beacon — {err} raw={data[:120]}")
return
store_beacon(conn, ev, received_at)
node = ev.get("node_id", "?")
ssid = ev.get("ssid") or "<hidden>"
bssid = ev.get("bssid", "?")
rssi = ev.get("rssi", 0)
ch = ev.get("ch", 0)
enc = ev.get("enc", "?")
imp = ev.get("imp", "normal")
color = IMP_COLOR.get(imp, "")
print(f"{color}[{received_at}] {node} {ssid:<32} {bssid} ch{ch:<3} {rssi:>4}dBm {enc}{RESET}")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
conn = init_db(DB_PATH)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((UDP_IP, UDP_PORT))
print(f"[INGESTOR] Listening on UDP :{UDP_PORT}")
print(f"[INGESTOR] Storing events to {DB_PATH}")
print(f"{'' * 80}")
try:
while True:
data, addr = sock.recvfrom(1024)
handle_packet(data, addr, conn)
except KeyboardInterrupt:
print("\n[INGESTOR] Stopped.")
finally:
conn.close()
sock.close()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=ESP32 Recon — UDP Ingestor
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/esp32_cluster/coordinator
ExecStart=/usr/bin/python3 udp_ingest.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Sync coordinator files to the Orange Pi.
# Run from this PC whenever you want to push changes.
# Firmware flashing is always done locally — never synced.
REMOTE="root@192.168.1.133"
REMOTE_DIR="/opt/esp32_cluster/coordinator"
echo "[DEPLOY] Syncing coordinator to ${REMOTE}:${REMOTE_DIR}"
rsync -av --progress --delete \
--exclude='events.db' \
--exclude='__pycache__' \
--exclude='*.pyc' \
/home/sandbox/Documents/projects/esp32_cluster/coordinator/ \
"${REMOTE}:${REMOTE_DIR}/"
echo "[DEPLOY] Done."
echo ""
echo "To restart services on the Orange Pi:"
echo " ssh ${REMOTE} 'systemctl restart udp_ingest dashboard'"
echo ""
echo "To check service status:"
echo " ssh ${REMOTE} 'systemctl status udp_ingest dashboard'"
+20
View File
@@ -0,0 +1,20 @@
#pragma once
// ─── WiFi ────────────────────────────────────────────────────────────────────
#define WIFI_SSID "sandbox"
#define WIFI_PASSWORD "Jaunsgads11!!"
// ─── Coordinator ─────────────────────────────────────────────────────────────
// This PC (192.168.1.101) runs coordinator.py during the test phase.
// Will change to Orange Pi (192.168.1.133) when we migrate.
#define COORDINATOR_IP "192.168.1.133"
#define COORDINATOR_PORT 5005
// ─── Scan ────────────────────────────────────────────────────────────────────
#define SCAN_INTERVAL_MS 15000 // how often to scan (ms)
#define HEARTBEAT_INTERVAL_MS 10000 // how often to send a heartbeat (ms)
// ─── Probe sniffing ──────────────────────────────────────────────────────────
#define PROBE_SNIFF 1 // 1 = enabled, 0 = disabled
#define PROBE_DEDUP_SECS 30 // suppress same MAC+SSID within this window (seconds)
#define PROBE_QUEUE_SIZE 32 // max probe events buffered between scan cycles
+309
View File
@@ -0,0 +1,309 @@
#include <WiFi.h>
#include <WiFiUdp.h>
#include "esp_efuse.h"
#include "esp_mac.h"
#include "config.h"
#if PROBE_SNIFF
#include "esp_wifi.h"
#include "freertos/queue.h"
#endif
WiFiUDP udp;
String nodeId;
unsigned long lastScan = 0;
unsigned long lastHeartbeat = 0;
// ─── Probe sniffing globals ───────────────────────────────────────────────────
#if PROBE_SNIFF
struct ProbeEvent {
uint8_t src_mac[6];
char ssid[33];
int8_t rssi;
};
static QueueHandle_t probeQueue;
// Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS
struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; };
static DedupEntry dedupCache[32];
static int dedupNext = 0;
static bool isDuplicate(const uint8_t* mac, const char* ssid) {
uint32_t now = millis();
for (int i = 0; i < 32; i++) {
if (dedupCache[i].last_ms == 0) continue;
if (memcmp(dedupCache[i].mac, mac, 6) == 0 &&
strcmp(dedupCache[i].ssid, ssid) == 0) {
if ((now - dedupCache[i].last_ms) < (uint32_t)(PROBE_DEDUP_SECS * 1000))
return true;
dedupCache[i].last_ms = now;
return false;
}
}
// Not found — write to next slot (ring)
memcpy(dedupCache[dedupNext].mac, mac, 6);
strncpy(dedupCache[dedupNext].ssid, ssid, 32);
dedupCache[dedupNext].ssid[32] = '\0';
dedupCache[dedupNext].last_ms = now;
dedupNext = (dedupNext + 1) % 32;
return false;
}
// Promiscuous callback — runs in WiFi task context, not safe to call UDP here.
// Parse probe request frames and push to queue for the main loop to send.
static void promiscuous_rx_cb(void* buf, wifi_promiscuous_pkt_type_t type) {
if (type != WIFI_PKT_MGMT) return;
const wifi_promiscuous_pkt_t* pkt = (const wifi_promiscuous_pkt_t*)buf;
const uint8_t* d = pkt->payload;
uint16_t len = pkt->rx_ctrl.sig_len;
if (len < 28) return;
if (d[0] != 0x40) return; // byte 0 = 0x40 → management, probe request subtype
const uint8_t* src = d + 10; // source address at bytes 1015
// Parse SSID tag (tag 0) from frame body starting at byte 24
char ssid[33] = "";
if (d[24] == 0x00) {
uint8_t slen = d[25];
if (slen > 0 && slen <= 32 && (26 + slen) <= len) {
bool printable = true;
for (uint8_t i = 0; i < slen; i++) {
if (d[26 + i] < 32 || d[26 + i] > 126) { printable = false; break; }
}
if (printable) { memcpy(ssid, d + 26, slen); ssid[slen] = '\0'; }
}
}
if (isDuplicate(src, ssid)) return;
ProbeEvent ev;
memcpy(ev.src_mac, src, 6);
memcpy(ev.ssid, ssid, 33);
ev.rssi = (int8_t)pkt->rx_ctrl.rssi;
xQueueSend(probeQueue, &ev, 0); // 0 timeout: drop if queue full
}
#endif // PROBE_SNIFF
// ─── Setup ───────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
// Derive node ID from MAC (last 4 bytes, no colons)
uint8_t mac[6];
esp_efuse_mac_get_default(mac);
char macBuf[9];
snprintf(macBuf, sizeof(macBuf), "%02X%02X%02X%02X", mac[2], mac[3], mac[4], mac[5]);
nodeId = String(macBuf);
Serial.printf("\n[NODE] ID: %s\n", nodeId.c_str());
connectWiFi();
#if PROBE_SNIFF
memset(dedupCache, 0, sizeof(dedupCache));
probeQueue = xQueueCreate(PROBE_QUEUE_SIZE, sizeof(ProbeEvent));
esp_wifi_set_promiscuous_rx_cb(promiscuous_rx_cb);
esp_wifi_set_promiscuous(true);
Serial.println("[PROBE] Promiscuous mode enabled");
#endif
}
// ─── Loop ────────────────────────────────────────────────────────────────────
void loop() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[WIFI] Lost connection, reconnecting...");
connectWiFi();
return;
}
unsigned long now = millis();
if (now - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
lastHeartbeat = now;
sendHeartbeat();
}
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
scanAndSend();
}
delay(100);
}
// ─── WiFi ────────────────────────────────────────────────────────────────────
void connectWiFi() {
Serial.printf("[WIFI] Connecting to %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WIFI] Connected — IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n[WIFI] Failed to connect, will retry in loop");
}
}
// ─── Scan ────────────────────────────────────────────────────────────────────
void scanAndSend() {
Serial.println("[SCAN] Starting...");
int n = WiFi.scanNetworks(false, true); // async=false, show_hidden=true
if (n == WIFI_SCAN_FAILED) {
Serial.println("[SCAN] Failed");
return;
}
Serial.printf("[SCAN] Found %d networks\n", n);
for (int i = 0; i < n; i++) {
sendBeaconEvent(i);
}
WiFi.scanDelete();
#if PROBE_SNIFF
// Scan may have disabled promiscuous mode internally — re-enable it.
esp_wifi_set_promiscuous(true);
flushProbeQueue();
#endif
}
// ─── Event helpers ───────────────────────────────────────────────────────────
const char* encStr(wifi_auth_mode_t enc) {
switch (enc) {
case WIFI_AUTH_OPEN: return "OPEN";
case WIFI_AUTH_WEP: return "WEP";
case WIFI_AUTH_WPA_PSK: return "WPA";
case WIFI_AUTH_WPA2_PSK: return "WPA2";
case WIFI_AUTH_WPA_WPA2_PSK: return "WPA/WPA2";
case WIFI_AUTH_WPA3_PSK: return "WPA3";
case WIFI_AUTH_WPA2_WPA3_PSK: return "WPA2/WPA3";
default: return "UNKNOWN";
}
}
const char* importance(int rssi) {
if (rssi >= -50) return "high";
if (rssi <= -80) return "low";
return "normal";
}
// ─── Send ────────────────────────────────────────────────────────────────────
void sendBeaconEvent(int idx) {
String ssid = WiFi.SSID(idx);
ssid.replace("\"", "\\\""); // escape quotes for JSON
String bssid = WiFi.BSSIDstr(idx);
int rssi = WiFi.RSSI(idx);
int ch = WiFi.channel(idx);
const char* enc = encStr(WiFi.encryptionType(idx));
const char* imp = importance(rssi);
unsigned long ts = millis();
char buf[512];
snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"beacon\","
"\"conf\":\"high\",\"imp\":\"%s\","
"\"ssid\":\"%s\",\"bssid\":\"%s\","
"\"rssi\":%d,\"ch\":%d,\"enc\":\"%s\"}",
nodeId.c_str(), ts, imp,
ssid.c_str(), bssid.c_str(),
rssi, ch, enc
);
udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT);
udp.print(buf);
udp.endPacket();
Serial.printf(" → %-32s %s ch%-3d %4ddBm %s\n",
ssid.c_str(), bssid.c_str(), ch, rssi, enc);
}
// ─── Heartbeat ───────────────────────────────────────────────────────────────
void sendHeartbeat() {
char buf[256];
snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"heartbeat\","
"\"uptime_ms\":%lu,\"free_heap\":%lu,\"wifi_rssi\":%d}",
nodeId.c_str(), millis(),
millis(),
(unsigned long)ESP.getFreeHeap(),
WiFi.RSSI()
);
udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT);
udp.print(buf);
udp.endPacket();
Serial.printf("[HB] uptime=%lus heap=%luK ap=%ddBm\n",
millis() / 1000,
(unsigned long)ESP.getFreeHeap() / 1024,
WiFi.RSSI()
);
}
// ─── Probe send / flush ───────────────────────────────────────────────────────
#if PROBE_SNIFF
void sendProbeEvent(const ProbeEvent& ev) {
char mac_str[18];
snprintf(mac_str, sizeof(mac_str), "%02X:%02X:%02X:%02X:%02X:%02X",
ev.src_mac[0], ev.src_mac[1], ev.src_mac[2],
ev.src_mac[3], ev.src_mac[4], ev.src_mac[5]);
// Escape any quotes in SSID
char ssidEsc[66];
int j = 0;
for (int i = 0; ev.ssid[i] && j < 64; i++) {
if (ev.ssid[i] == '"' || ev.ssid[i] == '\\') ssidEsc[j++] = '\\';
ssidEsc[j++] = ev.ssid[i];
}
ssidEsc[j] = '\0';
char buf[256];
snprintf(buf, sizeof(buf),
"{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"probe\","
"\"conf\":\"high\",\"imp\":\"%s\","
"\"src_mac\":\"%s\",\"ssid\":\"%s\",\"rssi\":%d}",
nodeId.c_str(), millis(), importance(ev.rssi),
mac_str, ssidEsc, (int)ev.rssi
);
udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT);
udp.print(buf);
udp.endPacket();
Serial.printf(" [PROBE] %s → \"%s\" %ddBm\n",
mac_str, ev.ssid[0] ? ev.ssid : "<wildcard>", (int)ev.rssi);
}
void flushProbeQueue() {
if (WiFi.status() != WL_CONNECTED) return;
ProbeEvent ev;
while (xQueueReceive(probeQueue, &ev, 0) == pdTRUE) {
sendProbeEvent(ev);
}
}
#endif // PROBE_SNIFF