diff --git a/README.md b/README.md index 0adf230..876a7f6 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,24 @@ Each node: } ``` +**Deauth / Disassoc event** (management frame captured in promiscuous mode): +```json +{ + "node_id": "F68D6E30", + "ts": 12345, + "type": "deauth", + "src": "AA:BB:CC:DD:EE:FF", + "dst": "11:22:33:44:55:66", + "bssid": "AA:BB:CC:DD:EE:FF", + "reason": 7, + "rssi": -61 +} +``` +- `type` is `deauth` (0xC0) or `disassoc` (0xA0) +- `dst = FF:FF:FF:FF:FF:FF` means broadcast deauth — a classic deauth flood signature +- `reason` is the 802.11 reason code (7 = class 3 frame received from non-associated station, common in attack tools) +- Captured passively in the same promiscuous callback as probe requests, flushed to coordinator after each scan cycle + **Heartbeat event** (node health, sent every 10 seconds): ```json { @@ -231,6 +249,9 @@ Open `http://192.168.1.133:8080` in your browser. - **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. +- **Alerts** — deauth/disassoc frame detection with two sections: + - *Detected Bursts* — fires when ≥10 deauth or disassoc frames are seen for the same BSSID within a 5-minute window. Highlighted red when confirmed by 2+ nodes (high confidence, source is physically nearby). Burst rows include BSSID, SSID, frame count, unique source MACs, and node count. + - *Raw Feed* — last 100 deauth/disassoc events with src, dst, BSSID, reason code, and RSSI. Useful for inspecting specific events. `dst = FF:FF:FF:FF:FF:FF` (broadcast deauth) is a classic attack tool signature. - **Presence** — three sections driven entirely by probe data: - *Present Now* — real (non-randomized) MACs seen 2+ times in the last hour. Filters out the city-center noise of transient devices. - *New Arrivals* — devices and networks first seen in the last 24 hours, split into two side-by-side tables. @@ -244,7 +265,7 @@ A node is considered **online** if a heartbeat was received within the last 30 s ### Live updates -SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests. +SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence, alerts) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests. --- @@ -263,6 +284,14 @@ What probe data tells you: - **Wildcard probes** (empty SSID): the device is searching for any available network - Probe data maps **client devices**, not infrastructure — complementary to beacon scan data +### Deauth frame noise vs real attacks + +The raw deauth feed in the Alerts tab will fill up constantly — deauth and disassoc frames are a normal part of WiFi operation (devices disconnecting, APs doing band steering, power saving). This is expected and not cause for concern. + +The **Bursts** section is the anomaly detector. A single unique source MAC generating 10+ deauth frames for the same BSSID within 5 minutes is not normal operation — it indicates a deauth flood, the standard precursor to a WPA2 handshake capture attack. The attacker forces connected clients to disconnect, captures the 4-way handshake when they reconnect, and then cracks it offline. + +Multi-node confirmation (`node_count > 1`, highlighted red) significantly raises confidence — it means the source is physically close and strong, not a distant weak signal. + ### 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`. @@ -283,6 +312,7 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status - [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range) - [x] Presence tab — Present Now, New Arrivals, Regulars +- [x] Deauth/disassoc frame detection — burst detection with multi-node confirmation, Alerts tab - [ ] Scan interval control from dashboard - [ ] DB pruning / retention policy (events.db grows indefinitely) diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index 5b28884..be1767a 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -354,6 +354,69 @@ def build_presence() -> dict: } +def build_alerts() -> dict: + now = datetime.datetime.utcnow() + cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds") + cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds") + + # Recent raw deauth/disassoc events + recent = query(""" + SELECT * FROM deauth_events + ORDER BY id DESC LIMIT 100 + """) + + # Burst detection: >= 10 frames for the same BSSID within the last 5 minutes. + # node_count > 1 means multiple sensors confirmed the burst — much higher confidence. + bursts = query(""" + SELECT + bssid, + subtype, + COUNT(*) AS count, + COUNT(DISTINCT node_id) AS node_count, + COUNT(DISTINCT src) AS unique_srcs, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen + FROM deauth_events + WHERE received_at > ? + GROUP BY bssid, subtype + HAVING COUNT(*) >= 10 + ORDER BY count DESC + """, (cutoff_5m,)) + + # Correlate burst BSSIDs with known network SSIDs + if bursts: + bssid_list = [b["bssid"] for b in bursts] + placeholders = ",".join("?" * len(bssid_list)) + ssid_rows = query(f""" + SELECT bssid, MAX(ssid) AS ssid, MAX(encryption) AS encryption + FROM beacon_events WHERE bssid IN ({placeholders}) + GROUP BY bssid + """, tuple(bssid_list)) + ssid_map = {r["bssid"]: r for r in ssid_rows} + for b in bursts: + net = ssid_map.get(b["bssid"], {}) + b["ssid"] = net.get("ssid") + b["encryption"] = net.get("encryption") + + # Summary counts for the last hour + summary = query(""" + SELECT + COUNT(*) AS total, + COUNT(DISTINCT bssid) AS unique_bssids, + COUNT(DISTINCT src) AS unique_srcs, + SUM(CASE WHEN subtype='deauth' THEN 1 ELSE 0 END) AS deauth_count, + SUM(CASE WHEN subtype='disassoc' THEN 1 ELSE 0 END) AS disassoc_count + FROM deauth_events + WHERE received_at > ? + """, (cutoff_1h,)) + + return { + "recent": recent, + "bursts": bursts, + "summary": summary[0] if summary else {}, + } + + def build_node_detail(node_id: str) -> dict | None: rows = query(""" SELECT @@ -418,6 +481,20 @@ def ensure_schema(): uptime_ms INTEGER, free_heap INTEGER, wifi_rssi INTEGER ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS deauth_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + node_id TEXT, + node_ts INTEGER, + subtype TEXT, + src TEXT, + dst TEXT, + bssid TEXT, + reason INTEGER, + rssi INTEGER + ) + """) conn.commit() @@ -490,6 +567,11 @@ async def api_rssi_history(bssid: str, hours: int = 2): return series +@app.get("/api/alerts") +async def api_alerts(): + return build_alerts() + + @app.get("/api/heartbeats") async def api_heartbeats(): """Latest heartbeat per node.""" diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index 1e87e2d..2fd8807 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -379,6 +379,26 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); } margin-bottom: 6px; } +/* ── Alerts ── */ +.alerts-summary { + display: flex; + gap: 16px; + flex-wrap: wrap; + padding: 10px 14px; + background: var(--bg-panel); + border: 1px solid var(--border); + font-size: 11px; + color: var(--text-dim); +} +.alerts-summary .stat { display: flex; flex-direction: column; gap: 2px; } +.alerts-summary .stat .val { font-size: 18px; font-weight: 500; color: var(--text); } +.alerts-summary .stat.alert .val { color: #e74c3c; } +.burst-row { background: rgba(231,76,60,0.07); } +.burst-row:hover { background: rgba(231,76,60,0.14) !important; } +.burst-multi { color: #e74c3c; font-weight: 500; } +td.deauth-type { color: #e74c3c; } +td.disassoc-type { color: var(--orange); } + /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 53c6c34..3055e55 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -9,6 +9,7 @@ let netSort = { col: 'times_seen', dir: 'desc' }; let crossNodeData = { node_ids: [], networks: [] }; let clientsData = []; let presenceData = {}; +let alertsData = {}; let chartBssid = null; let chartHours = 2; @@ -437,6 +438,95 @@ function renderPresenceView() { } } +// ── Alerts view ─────────────────────────────────────────────────────────────── +async function fetchAlerts() { + alertsData = await fetch('/api/alerts').then(r => r.json()); + renderAlertsView(); +} + +function renderAlertsView() { + const a = alertsData; + const sum = a.summary || {}; + + // Summary strip + document.getElementById('alerts-summary').innerHTML = ` +
+ ${sum.total ?? 0} + events (last 1h) +
+
+ ${sum.deauth_count ?? 0} + deauth +
+
+ ${sum.disassoc_count ?? 0} + disassoc +
+
+ ${sum.unique_bssids ?? 0} + unique BSSIDs +
+
+ ${sum.unique_srcs ?? 0} + unique sources +
`; + + // Bursts + const burstsTbody = document.getElementById('bursts-tbody'); + const burstsEmpty = document.getElementById('bursts-empty'); + const burstsCount = document.getElementById('bursts-count'); + const bursts = a.bursts || []; + burstsCount.textContent = bursts.length ? `${bursts.length} active` : ''; + if (!bursts.length) { + burstsTbody.innerHTML = ''; + burstsEmpty.style.display = 'block'; + } else { + burstsEmpty.style.display = 'none'; + burstsTbody.innerHTML = bursts.map(b => { + const multiNode = b.node_count > 1; + const rowCls = multiNode ? 'burst-row burst-multi' : 'burst-row'; + const nodeCls = multiNode ? 'burst-multi' : 'muted'; + const typeCls = b.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type'; + return ` + ${fmt(b.bssid)} + ${fmt(b.ssid, '')} + ${b.subtype} + ${b.count} + ${b.unique_srcs} + ${b.node_count} + ${shortTime(b.first_seen)} + ${shortTime(b.last_seen)} + `; + }).join(''); + } + + // Raw deauth feed + const deauthTbody = document.getElementById('deauth-tbody'); + const deauthEmpty = document.getElementById('deauth-empty'); + const deauthCount = document.getElementById('deauth-count'); + const recent = a.recent || []; + deauthCount.textContent = `${recent.length} events`; + if (!recent.length) { + deauthTbody.innerHTML = ''; + deauthEmpty.style.display = 'block'; + } else { + deauthEmpty.style.display = 'none'; + deauthTbody.innerHTML = recent.map(r => { + const typeCls = r.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type'; + return ` + ${shortTime(r.received_at)} + ${fmt(r.node_id)} + ${r.subtype} + ${fmt(r.src)} + ${fmt(r.dst)} + ${fmt(r.bssid)} + ${fmt(r.reason)} + ${fmt(r.rssi)} dBm + `; + }).join(''); + } +} + // ── RSSI chart ──────────────────────────────────────────────────────────────── async function showNetworkChart(bssid) { chartBssid = bssid; @@ -568,11 +658,13 @@ function setView(view) { document.getElementById('view-clients').classList.toggle('active', view === 'clients'); document.getElementById('view-detail').classList.toggle('active', view === 'detail'); document.getElementById('view-presence').classList.toggle('active', view === 'presence'); + document.getElementById('view-alerts').classList.toggle('active', view === 'alerts'); 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'); document.getElementById('tab-presence').classList.toggle('active', view === 'presence'); + document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts'); } function showFeed() { @@ -591,6 +683,8 @@ async function showClients() { setView('clients'); await fetchClients(); } async function showPresence() { setView('presence'); await fetchPresence(); } +async function showAlerts() { setView('alerts'); await fetchAlerts(); } + async function showDetail(node_id) { currentNode = node_id; setView('detail'); @@ -643,6 +737,7 @@ function startSSE() { else if (currentView === 'network-chart') await loadAndRenderChart(); else if (currentView === 'crossnode') await fetchCrossNode(); else if (currentView === 'presence') await fetchPresence(); + else if (currentView === 'alerts') await fetchAlerts(); const nodes = await fetch('/api/nodes').then(r => r.json()); nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); diff --git a/coordinator/templates/dashboard.html b/coordinator/templates/dashboard.html index 1e6e9cc..d26b482 100644 --- a/coordinator/templates/dashboard.html +++ b/coordinator/templates/dashboard.html @@ -28,6 +28,7 @@ + @@ -304,6 +305,47 @@ + +
+ + +
+ + +
+ Detected Bursts + + ≥10 deauth/disassoc frames for same BSSID in last 5 min +
+
+ + + + + + +
BSSIDSSIDTypeFramesUnique SrcsNodesFirstLast
+ +
+ + +
+ Recent Deauth / Disassoc Events + +
+
+ + + + + + +
TimeNodeTypeSrcDstBSSIDReasonRSSI
+ +
+ +
+ diff --git a/coordinator/udp_ingest.py b/coordinator/udp_ingest.py index fd2c5c3..9c2d5db 100644 --- a/coordinator/udp_ingest.py +++ b/coordinator/udp_ingest.py @@ -57,6 +57,20 @@ def init_db(path: str) -> sqlite3.Connection: wifi_rssi INTEGER ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS deauth_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, + node_id TEXT, + node_ts INTEGER, + subtype TEXT, + src TEXT, + dst TEXT, + bssid TEXT, + reason INTEGER, + rssi INTEGER + ) + """) conn.commit() return conn @@ -149,6 +163,42 @@ def validate_heartbeat(ev: dict) -> str | None: return f"wifi_rssi out of range: {ev['wifi_rssi']}" return None +def store_deauth(conn: sqlite3.Connection, ev: dict, received_at: str): + conn.execute(""" + INSERT INTO deauth_events + (received_at, node_id, node_ts, subtype, src, dst, bssid, reason, rssi) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + received_at, + ev.get("node_id"), + ev.get("ts"), + ev.get("type"), + ev.get("src"), + ev.get("dst"), + ev.get("bssid"), + ev.get("reason"), + ev.get("rssi"), + )) + conn.commit() + + +def validate_deauth(ev: dict) -> str | None: + for field in ("node_id", "src", "dst", "bssid", "reason", "rssi"): + if ev.get(field) is None: + return f"missing field '{field}'" + if not _check_mac(ev["src"]): + return f"bad src format: {ev['src']}" + if not _check_mac(ev["dst"]): + return f"bad dst format: {ev['dst']}" + if not _check_mac(ev["bssid"]): + return f"bad bssid format: {ev['bssid']}" + if not isinstance(ev["reason"], int) or not (0 <= ev["reason"] <= 65535): + return f"invalid reason code: {ev['reason']}" + if not _check_rssi(ev["rssi"]): + return f"rssi out of range: {ev['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"): @@ -182,7 +232,20 @@ def handle_packet(data: bytes, addr: tuple, conn: sqlite3.Connection): received_at = datetime.datetime.utcnow().isoformat(timespec="seconds") pkt_type = ev.get("type", "beacon") - if pkt_type == "heartbeat": + if pkt_type in ("deauth", "disassoc"): + err = validate_deauth(ev) + if err: + print(f"[DROP] {addr[0]} {pkt_type} — {err}") + return + store_deauth(conn, ev, received_at) + node = ev.get("node_id", "?") + src = ev.get("src", "?") + dst = ev.get("dst", "?") + bssid = ev.get("bssid", "?") + reason = ev.get("reason", 0) + rssi = ev.get("rssi", 0) + print(f"\033[91m[{received_at}] {node} {pkt_type.upper():<8} {src} → {dst} bssid={bssid} reason={reason} {rssi:>4}dBm{RESET}") + elif pkt_type == "heartbeat": err = validate_heartbeat(ev) if err: print(f"[DROP] {addr[0]} heartbeat — {err}") diff --git a/firmware/node/node.ino b/firmware/node/node.ino index fd4c5a9..7d4cfae 100644 --- a/firmware/node/node.ino +++ b/firmware/node/node.ino @@ -25,7 +25,17 @@ struct ProbeEvent { int8_t rssi; }; +struct DeauthEvent { + uint8_t src[6]; + uint8_t dst[6]; + uint8_t bssid[6]; + uint8_t subtype; // 0x0C = deauth, 0x0A = disassoc + uint16_t reason; + int8_t rssi; +}; + static QueueHandle_t probeQueue; +static QueueHandle_t deauthQueue; // Dedup cache — suppress repeated MAC+SSID pairs within PROBE_DEDUP_SECS struct DedupEntry { uint8_t mac[6]; char ssid[33]; uint32_t last_ms; }; @@ -54,39 +64,60 @@ static bool isDuplicate(const uint8_t* mac, const char* ssid) { } // 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. +// Parse probe request, deauth, and disassoc frames and push to queues. 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; + 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 + if (len < 24) return; + uint8_t subtype = d[0]; - const uint8_t* src = d + 10; // source address at bytes 10–15 + // ── Probe request (0x40) ───────────────────────────────────────────────── + if (subtype == 0x40) { + if (len < 28) return; + const uint8_t* src = d + 10; - // 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; } + 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 (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); + return; } - if (isDuplicate(src, ssid)) return; + // ── Deauth (0xC0) and Disassoc (0xA0) ─────────────────────────────────── + if (subtype == 0xC0 || subtype == 0xA0) { + // 802.11 frame header: addr1 (dst) @ 4, addr2 (src) @ 10, addr3 (bssid) @ 16 + // Frame body starts at byte 24: 2-byte reason code + if (len < 26) 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 + DeauthEvent ev; + memcpy(ev.dst, d + 4, 6); + memcpy(ev.src, d + 10, 6); + memcpy(ev.bssid, d + 16, 6); + ev.subtype = subtype; + ev.reason = (uint16_t)d[24] | ((uint16_t)d[25] << 8); + ev.rssi = (int8_t)pkt->rx_ctrl.rssi; + xQueueSend(deauthQueue, &ev, 0); + return; + } } #endif // PROBE_SNIFF @@ -109,7 +140,8 @@ void setup() { #if PROBE_SNIFF memset(dedupCache, 0, sizeof(dedupCache)); - probeQueue = xQueueCreate(PROBE_QUEUE_SIZE, sizeof(ProbeEvent)); + probeQueue = xQueueCreate(PROBE_QUEUE_SIZE, sizeof(ProbeEvent)); + deauthQueue = xQueueCreate(DEAUTH_QUEUE_SIZE, sizeof(DeauthEvent)); esp_wifi_set_promiscuous_rx_cb(promiscuous_rx_cb); esp_wifi_set_promiscuous(true); Serial.println("[PROBE] Promiscuous mode enabled"); @@ -184,6 +216,7 @@ void scanAndSend() { // Scan may have disabled promiscuous mode internally — re-enable it. esp_wifi_set_promiscuous(true); flushProbeQueue(); + flushDeauthQueue(); #endif } @@ -306,4 +339,42 @@ void flushProbeQueue() { } } +void sendDeauthEvent(const DeauthEvent& ev) { + auto macStr = [](const uint8_t* m, char* out) { + snprintf(out, 18, "%02X:%02X:%02X:%02X:%02X:%02X", + m[0], m[1], m[2], m[3], m[4], m[5]); + }; + char src[18], dst[18], bssid[18]; + macStr(ev.src, src); + macStr(ev.dst, dst); + macStr(ev.bssid, bssid); + + const char* stype = (ev.subtype == 0xC0) ? "deauth" : "disassoc"; + + char buf[384]; + snprintf(buf, sizeof(buf), + "{\"node_id\":\"%s\",\"ts\":%lu,\"type\":\"%s\"," + "\"src\":\"%s\",\"dst\":\"%s\",\"bssid\":\"%s\"," + "\"reason\":%u,\"rssi\":%d}", + nodeId.c_str(), millis(), stype, + src, dst, bssid, + (unsigned)ev.reason, (int)ev.rssi + ); + + udp.beginPacket(COORDINATOR_IP, COORDINATOR_PORT); + udp.print(buf); + udp.endPacket(); + + Serial.printf(" [%s] %s → %s bssid=%s reason=%u %ddBm\n", + stype, src, dst, bssid, (unsigned)ev.reason, (int)ev.rssi); +} + +void flushDeauthQueue() { + if (WiFi.status() != WL_CONNECTED) return; + DeauthEvent ev; + while (xQueueReceive(deauthQueue, &ev, 0) == pdTRUE) { + sendDeauthEvent(ev); + } +} + #endif // PROBE_SNIFF