#include #include #include "esp_wifi.h" #include #include #include // ============================================================ // CONFIG // ============================================================ #define BUZZER_PIN 3 #define USE_BUZZER 1 // Onboard user LED on Seeed XIAO ESP32-S3 is GPIO21 and is ACTIVE LOW // (driving the pin LOW lights the LED). #define LED_PIN 21 #define USE_LED 1 #define LED_ACTIVE_HIGH 0 #define LED_FLASH_MS 120 #define MIRROR_SERIAL 1 #define MIRROR_TX_PIN 43 #define MIRROR_BAUD 115200 #define CHANNEL_MODE_FULL_HOP 0 #define CHANNEL_MODE_CUSTOM 1 #define CHANNEL_MODE_SINGLE 2 #define CHANNEL_MODE CHANNEL_MODE_CUSTOM #define CHANNEL_DWELL_MS 350 #define SINGLE_CHANNEL 1 static const uint8_t customChannels[] = {1, 6, 11}; static const size_t customChannelCount = sizeof(customChannels) / sizeof(customChannels[0]); static const uint8_t fullHopChannels[] = {1,2,3,4,5,6,7,8,9,10,11}; static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(fullHopChannels[0]); #define HEARTBEAT_MS 30000 #define RSSI_MIN -95 #define ALERT_COOLDOWN_MS 5000 // Audio cadence: two fast ascending beeps on a NEW MAC, then while any // target is still in range (seen within HB_DEVICE_ACTIVE_MS), two monotone // heartbeat beeps every HB_BEEP_INTERVAL_MS. #define HB_DEVICE_ACTIVE_MS 3000 #define HB_BEEP_INTERVAL_MS 10000 // A MAC we haven't heard from in REDISCOVER_MS counts as a fresh discovery // next time it shows up — fires the ascending chirp again. Shorter than a // Flock's burst-sleep gap would mean false chirps; longer means you'd miss // a drive-away/return. 30 s is a good middle ground. #define REDISCOVER_MS 30000 #define NEW_CHIRP_LO_HZ 2000 #define NEW_CHIRP_HI_HZ 2800 #define NEW_CHIRP_NOTE_MS 55 #define NEW_CHIRP_GAP_MS 25 #define HB_BEEP_HZ 1500 #define HB_BEEP_NOTE_MS 70 #define HB_BEEP_GAP_MS 70 #define ENABLE_SSID_MATCH 0 #define CHECK_ADDR1 1 // dst/rx — catches Flock STAs receiving probe responses #define CHECK_ADDR3 0 // bssid fallback for randomised addr2 static const char* target_ssid_keywords[] = { "flock" }; static const size_t SSID_KEYWORD_COUNT = sizeof(target_ssid_keywords) / sizeof(target_ssid_keywords[0]); #define STOP_ON_SSID_HIT 0 #define STOP_ON_OUI_HIT 0 #define PROCESS_MGMT_FRAMES 1 #define PROCESS_DATA_FRAMES 1 // Persistence #define MAX_DETECTIONS 200 #define FY_SESSION_FILE "/session.json" #define FY_SESSION_TMP "/session.tmp" #define FY_PREV_FILE "/prev_session.json" #define AUTOSAVE_INTERVAL_MS 60000 // ============================================================ // TARGET OUI LIST (all lowercase, colons only) // ============================================================ static const char* target_ouis[] = { // @NitekryDPaul / OrdoOuroborous — original promiscuous-mode set, 29 OUIs. // f8:a2:d6 has been demoted (Sony Media Player false positive — see // nite-oui-collection/groups/flockers/my_tested_flock.md). "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "b8:35:32", "14:5a:fc", "74:4c:a1", "08:3a:88", "9c:2f:9d", "c0:35:32", "94:08:53", "e4:aa:ea", "f4:6a:dd", "24:b2:b9", "00:f4:8d", "d0:39:57", "e8:d0:fc", "e0:4f:43", "b8:1e:a4", "70:08:94", "58:8e:81", "ec:1b:bd", "3c:71:bf", "58:00:e3", "90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12", // @NitekryDPaul April 2026 additions (nite-oui-collection). "04:0d:84", "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", "b4:1e:52", "14:b5:cd", "94:2a:6f", "f4:e2:c6", "d4:11:d6", "e0:0a:f6", // Contributed by Michael / DeFlockJoplin — discovered via wildcard-probe // + OUI signature during field testing. The 12th camera in his drive-test // used this prefix and wasn't in @NitekryDPaul's original 30. "82:6b:f2" }; static const size_t OUI_COUNT = sizeof(target_ouis) / sizeof(target_ouis[0]); // Pre-compiled byte table — populated once in setup(), never touched again. // Keeps matchOuiRaw entirely in IRAM with no flash-resident function calls. static uint8_t oui_bytes[OUI_COUNT][3]; // ============================================================ // ALERT QUEUE (callback → loop, avoids Serial in WiFi task) // ============================================================ #define ALERT_QUEUE_SIZE 32 typedef enum : uint8_t { ALERT_OUI_ADDR2 = 0, ALERT_OUI_ADDR1 = 1, ALERT_OUI_ADDR3 = 2, ALERT_SSID = 3, // Probe Request + wildcard SSID (tag 0, length 0) from a known-OUI addr2. // Tight signature from Michael / DeFlockJoplin field research: // https://github.com/DeflockJoplin/flock-you ALERT_WILDCARD_PROBE = 4, } AlertType; typedef struct { AlertType type; uint8_t mac[6]; int8_t rssi; uint8_t channel; char ssid[33]; // populated for SSID hits char frameKind[12]; } AlertEntry; static volatile AlertEntry alertQueue[ALERT_QUEUE_SIZE]; static volatile size_t alertHead = 0; // written by callback static volatile size_t alertTail = 0; // read by loop() static portMUX_TYPE queueMux = portMUX_INITIALIZER_UNLOCKED; static void IRAM_ATTR enqueueAlert(AlertType type, const uint8_t* mac, int8_t rssi, uint8_t ch, const char* ssid, const char* kind) { portENTER_CRITICAL_ISR(&queueMux); size_t next = (alertHead + 1) % ALERT_QUEUE_SIZE; if (next == alertTail) { // drop if full — loop() is behind portEXIT_CRITICAL_ISR(&queueMux); return; } AlertEntry* e = (AlertEntry*)&alertQueue[alertHead]; e->type = type; e->rssi = rssi; e->channel = ch; memcpy((void*)e->mac, mac, 6); if (ssid) { strncpy((char*)e->ssid, ssid, 32); ((char*)e->ssid)[32] = '\0'; } else { ((char*)e->ssid)[0] = '\0'; } if (kind) { strncpy((char*)e->frameKind, kind, 11); ((char*)e->frameKind)[11] = '\0'; } else { ((char*)e->frameKind)[0] = '\0'; } alertHead = next; portEXIT_CRITICAL_ISR(&queueMux); } // ============================================================ // DETECTION TABLE (on-device storage, persisted to SPIFFS) // ============================================================ // // Single-threaded: only touched from loop() — drainAlertQueue() adds, and // fySaveSession() reads. No mutex needed. The WiFi-task callback never // touches this table; it only writes to the lock-free alert ring buffer. typedef struct { char mac[18]; char method[16]; // "oui_addr2" / "oui_addr1" / "oui_addr3" / "ssid" int8_t rssi; uint8_t channel; uint32_t firstSeen; // millis() at first hit uint32_t lastSeen; // millis() at latest hit uint16_t count; char ssid[33]; // "" unless an SSID hit populated it } FYDetection; static FYDetection fyDet[MAX_DETECTIONS]; static int fyDetCount = 0; static bool fySpiffsReady = false; static bool fyDirty = false; static unsigned long fyLastSaveAt = 0; static int fyLastSaveCount = 0; // ============================================================ // STATE // ============================================================ static uint8_t currentChannel = 1; static size_t customChannelIndex = 0; static size_t fullHopIndex = 0; static unsigned long lastHop = 0; static unsigned long lastHeartbeat = 0; static volatile bool sniffingStopped = false; // Dedupe table (small circular, avoids single-slot eviction bug). // This is the *serial-rate-limit* dedup — it suppresses beep + emit within // ALERT_COOLDOWN_MS of a prior hit on the same MAC. The detection table // (above) still counts every hit regardless of this suppression. #define DEDUPE_SLOTS 8 static struct { char mac[18]; unsigned long ts; } dedupeTable[DEDUPE_SLOTS]; static size_t dedupeIdx = 0; // LED one-shot pulse timer static volatile unsigned long ledOffAt = 0; // Heartbeat audio state: last time any target was seen, last time the // heartbeat beep-pair was played. When nothing has been seen for // HB_DEVICE_ACTIVE_MS the heartbeat stops until the next new detection. static unsigned long fyLastTargetSeen = 0; static unsigned long fyLastHeartbeatAt = 0; // ============================================================ // 802.11 HEADER // ============================================================ typedef struct __attribute__((packed)) { uint16_t frame_ctrl; uint16_t duration; uint8_t addr1[6]; uint8_t addr2[6]; uint8_t addr3[6]; uint16_t seq_ctrl; } wifi_ieee80211_mac_hdr_t; // ============================================================ // HELPERS // ============================================================ // Dual-output: prints to both Serial (USB) and Serial1 (GPIO43). // Sized to fit the longest line we emit: a replay-detection JSON record // with worst-case JSON-escaped SSID (32 chars → up to 192 bytes) plus the // envelope fields — ~600 B comfortably under 1024. static char _dualBuf[1024]; static void dualPrintf(const char* fmt, ...) __attribute__((format(printf, 1, 2))); static void dualPrintf(const char* fmt, ...) { va_list args; va_start(args, fmt); int n = vsnprintf(_dualBuf, sizeof(_dualBuf), fmt, args); va_end(args); if (n > 0) { Serial.write(_dualBuf, n); #if MIRROR_SERIAL Serial1.write(_dualBuf, n); #endif } } static void dualPrintln(const char* str) { Serial.println(str); #if MIRROR_SERIAL Serial1.println(str); #endif } static inline void ledSet(bool on) { #if USE_LED #if LED_ACTIVE_HIGH digitalWrite(LED_PIN, on ? HIGH : LOW); #else digitalWrite(LED_PIN, on ? LOW : HIGH); #endif #endif } static void ledFlash(unsigned ms) { #if USE_LED ledSet(true); ledOffAt = millis() + ms; if (ledOffAt == 0) ledOffAt = 1; // avoid the "off" sentinel #endif } static void ledTick() { #if USE_LED if (ledOffAt && (long)(millis() - ledOffAt) >= 0) { ledSet(false); ledOffAt = 0; } #endif } static void buzzerBeep(unsigned int ms) { #if USE_BUZZER digitalWrite(BUZZER_PIN, HIGH); delay(ms); digitalWrite(BUZZER_PIN, LOW); #endif } // Two fast ascending beeps — played on the FIRST sighting of a MAC. static void newDetectChirp() { #if USE_BUZZER tone(BUZZER_PIN, NEW_CHIRP_LO_HZ); delay(NEW_CHIRP_NOTE_MS); noTone(BUZZER_PIN); delay(NEW_CHIRP_GAP_MS); tone(BUZZER_PIN, NEW_CHIRP_HI_HZ); delay(NEW_CHIRP_NOTE_MS); noTone(BUZZER_PIN); #endif } // Two monotone beeps — periodic heartbeat while at least one target is still // in range (last seen within HB_DEVICE_ACTIVE_MS). static void heartbeatBeep() { #if USE_BUZZER tone(BUZZER_PIN, HB_BEEP_HZ); delay(HB_BEEP_NOTE_MS); noTone(BUZZER_PIN); delay(HB_BEEP_GAP_MS); tone(BUZZER_PIN, HB_BEEP_HZ); delay(HB_BEEP_NOTE_MS); noTone(BUZZER_PIN); #endif } static void startupBeep() { #if USE_BUZZER // First 6 notes of SMB World 1-2 (underground). Koji Kondo's descending // pattern: C5 → C4 → A4 → A3 → G#4 → G#3 (alternating-octave pairs). static const uint16_t notes[6] = { 523, 262, 440, 220, 415, 208 }; for (int i = 0; i < 6; i++) { tone(BUZZER_PIN, notes[i]); delay((i == 5) ? 160 : 95); noTone(BUZZER_PIN); if (i < 5) delay(22); } #endif } static void macToStr(const uint8_t* mac, char* buf, size_t len) { snprintf(buf, len, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); } static void ouiFromMac(const uint8_t* mac, char* buf, size_t len) { snprintf(buf, len, "%02x:%02x:%02x", mac[0], mac[1], mac[2]); } static void precompileOuis() { for (size_t i = 0; i < OUI_COUNT; i++) { const char* o = target_ouis[i]; oui_bytes[i][0] = (uint8_t)strtol(o, nullptr, 16); oui_bytes[i][1] = (uint8_t)strtol(o + 3, nullptr, 16); oui_bytes[i][2] = (uint8_t)strtol(o + 6, nullptr, 16); } } // Bit 0 of byte 0 set = multicast/broadcast — never a real device transmitter or receiver // we care about. Guards addr1 checks against 01:xx, 33:33:xx, ff:ff:ff:ff:ff:ff etc. static inline bool IRAM_ATTR isMulticast(const uint8_t* mac) { return mac[0] & 0x01; } static bool IRAM_ATTR matchOuiRaw(const uint8_t* mac) { // Locally-administered (randomised) MACs have bit 1 of byte 0 set. // Fixed infrastructure devices never use them — skip immediately. if (mac[0] & 0x02) return false; for (size_t i = 0; i < OUI_COUNT; i++) { if (mac[0] == oui_bytes[i][0] && mac[1] == oui_bytes[i][1] && mac[2] == oui_bytes[i][2]) return true; } return false; } static char* strcasestr_local(const char* haystack, const char* needle) { if (!*needle) return (char*)haystack; for (; *haystack; ++haystack) { const char* h = haystack; const char* n = needle; while (*h && *n && tolower((unsigned char)*h) == tolower((unsigned char)*n)) { ++h; ++n; } if (!*n) return (char*)haystack; } return nullptr; } static bool matchSsidKeyword(const char* ssid) { for (size_t i = 0; i < SSID_KEYWORD_COUNT; i++) if (strcasestr_local(ssid, target_ssid_keywords[i])) return true; return false; } static const char* channelModeName() { switch (CHANNEL_MODE) { case CHANNEL_MODE_FULL_HOP: return "FULL_HOP"; case CHANNEL_MODE_CUSTOM: return "CUSTOM"; case CHANNEL_MODE_SINGLE: return "SINGLE"; default: return "UNKNOWN"; } } static inline uint16_t channelFreqMhz(uint8_t ch) { return (ch >= 1 && ch <= 14) ? (uint16_t)(2407 + 5 * ch) : 0; } static bool shouldSuppressDuplicate(const char* macStr) { unsigned long now = millis(); for (size_t i = 0; i < DEDUPE_SLOTS; i++) { if (strcmp(dedupeTable[i].mac, macStr) == 0) { if ((now - dedupeTable[i].ts) < ALERT_COOLDOWN_MS) return true; dedupeTable[i].ts = now; return false; } } // Not found — insert into next slot strlcpy(dedupeTable[dedupeIdx].mac, macStr, 18); dedupeTable[dedupeIdx].ts = now; dedupeIdx = (dedupeIdx + 1) % DEDUPE_SLOTS; return false; } static void stopSniffing(const char* reason) { if (sniffingStopped) return; sniffingStopped = true; esp_wifi_set_promiscuous(false); dualPrintf("[flockyou] sniffing stopped: %s\n", reason); } static void applyInitialChannel() { #if CHANNEL_MODE == CHANNEL_MODE_SINGLE currentChannel = SINGLE_CHANNEL; #elif CHANNEL_MODE == CHANNEL_MODE_CUSTOM currentChannel = customChannels[0]; #else currentChannel = fullHopChannels[0]; #endif esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); lastHop = millis(); // start dwell timer precisely when channel is first set } static void updateChannelMode() { if (sniffingStopped) return; #if CHANNEL_MODE == CHANNEL_MODE_SINGLE if (currentChannel != SINGLE_CHANNEL) { currentChannel = SINGLE_CHANNEL; esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); } return; #else if (millis() - lastHop < CHANNEL_DWELL_MS) return; #if CHANNEL_MODE == CHANNEL_MODE_CUSTOM customChannelIndex = (customChannelIndex + 1) % customChannelCount; currentChannel = customChannels[customChannelIndex]; #else fullHopIndex = (fullHopIndex + 1) % fullHopChannelCount; currentChannel = fullHopChannels[fullHopIndex]; #endif esp_wifi_set_channel(currentChannel, WIFI_SECOND_CHAN_NONE); lastHop = millis(); #endif } static void printHeartbeat() { if (millis() - lastHeartbeat >= HEARTBEAT_MS) { dualPrintf("[flockyou] scanning (ch=%u mode=%s det=%d)\n", currentChannel, channelModeName(), fyDetCount); lastHeartbeat = millis(); } } // ============================================================ // DETECTION TABLE OPS // ============================================================ static const char* alertTypeToMethod(AlertType t) { switch (t) { case ALERT_OUI_ADDR2: return "oui_addr2"; case ALERT_OUI_ADDR1: return "oui_addr1"; case ALERT_OUI_ADDR3: return "oui_addr3"; case ALERT_SSID: return "ssid"; case ALERT_WILDCARD_PROBE: return "wildcard_probe"; default: return "unknown"; } } // Returns index of entry (new or updated), or -1 if table is full. // Returns index, and sets *outChirpWorthy = true when the caller should fire // the ascending new-discovery chirp. Chirp-worthy means either (a) MAC is // brand new to this session, or (b) MAC is known but hasn't been seen in // REDISCOVER_MS — i.e. it left RF range and came back. static int fyAddDetection(const char* mac, const char* method, int8_t rssi, uint8_t ch, const char* ssid, bool* outChirpWorthy) { uint32_t now = millis(); for (int i = 0; i < fyDetCount; i++) { if (strcasecmp(fyDet[i].mac, mac) == 0) { bool rediscover = (now - fyDet[i].lastSeen) > REDISCOVER_MS; if (fyDet[i].count < 0xFFFF) fyDet[i].count++; fyDet[i].lastSeen = now; fyDet[i].rssi = rssi; fyDet[i].channel = ch; if (ssid && ssid[0] && !fyDet[i].ssid[0]) { strlcpy(fyDet[i].ssid, ssid, sizeof(fyDet[i].ssid)); } fyDirty = true; if (outChirpWorthy) *outChirpWorthy = rediscover; return i; } } if (fyDetCount >= MAX_DETECTIONS) { if (outChirpWorthy) *outChirpWorthy = false; return -1; } FYDetection& d = fyDet[fyDetCount]; strlcpy(d.mac, mac, sizeof(d.mac)); strlcpy(d.method, method ? method : "", sizeof(d.method)); d.rssi = rssi; d.channel = ch; d.firstSeen = now; d.lastSeen = now; d.count = 1; if (ssid && ssid[0]) strlcpy(d.ssid, ssid, sizeof(d.ssid)); else d.ssid[0] = '\0'; fyDetCount++; fyDirty = true; if (outChirpWorthy) *outChirpWorthy = true; return fyDetCount - 1; } // ============================================================ // JSON ESCAPE — only needed for SSIDs (user-controlled bytes) // ============================================================ static size_t jsonEscape(char* dst, size_t cap, const char* src) { size_t o = 0; if (cap == 0) return 0; for (size_t i = 0; src[i]; i++) { char c = src[i]; if (c == '"' || c == '\\') { if (o + 2 >= cap) break; dst[o++] = '\\'; dst[o++] = c; } else if ((unsigned char)c < 0x20) { if (o + 6 >= cap) break; int n = snprintf(dst + o, cap - o, "\\u%04x", (unsigned)(unsigned char)c); if (n <= 0 || (size_t)n >= cap - o) break; o += (size_t)n; } else { if (o + 1 >= cap) break; dst[o++] = c; } } dst[o] = '\0'; return o; } // ============================================================ // CRC32 (zlib / SPIFFS-tool compatible polynomial 0xEDB88320) // ============================================================ static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) { crc = ~crc; for (size_t i = 0; i < len; i++) { crc ^= data[i]; for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & -(int32_t)(crc & 1)); } return ~crc; } // ============================================================ // SPIFFS SESSION PERSISTENCE — bulletproof envelope format // ============================================================ // // Wire format on disk: // Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n // Line 2+: [{"mac":...},...] (exactly B bytes, CRC32 == X) // // Atomic write procedure: // 1. Compute payload size + CRC (pass 1) // 2. Write envelope + payload to /session.tmp (pass 2) // 3. Re-validate /session.tmp from disk // 4. Remove /session.json, rename tmp → main (with copy+delete fallback) // // Boot-time recovery: // - Try /session.json. If missing or CRC-invalid, try /session.tmp. // - Copy whichever validates to /prev_session.json, then delete both. static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) { char ssidEsc[sizeof(d.ssid) * 6 + 1]; jsonEscape(ssidEsc, sizeof(ssidEsc), d.ssid); int n = snprintf(dst, cap, "{\"mac\":\"%s\",\"method\":\"%s\",\"rssi\":%d,\"channel\":%u," "\"first\":%lu,\"last\":%lu,\"count\":%u,\"ssid\":\"%s\"}", d.mac, d.method, d.rssi, (unsigned)d.channel, (unsigned long)d.firstSeen, (unsigned long)d.lastSeen, (unsigned)d.count, ssidEsc); return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; } static uint32_t fyComputePayloadCRC(size_t& outBytes) { char line[384]; uint32_t crc = 0; outBytes = 0; crc = fyCRC32Update(crc, (const uint8_t*)"[", 1); outBytes += 1; for (int i = 0; i < fyDetCount; i++) { if (i > 0) { crc = fyCRC32Update(crc, (const uint8_t*)",", 1); outBytes += 1; } size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); if (n == 0) continue; crc = fyCRC32Update(crc, (const uint8_t*)line, n); outBytes += n; } crc = fyCRC32Update(crc, (const uint8_t*)"]", 1); outBytes += 1; return crc; } // Minimal envelope parser: pulls bytes + crc fields by substring search. // Robust to field reordering; rejects anything without both required keys. static bool fyParseEnvelope(const char* hdr, size_t& outBytes, uint32_t& outCrc) { const char* b = strstr(hdr, "\"bytes\":"); const char* c = strstr(hdr, "\"crc\":\"0x"); if (!b || !c) return false; b += 8; long long bv = 0; if (sscanf(b, "%lld", &bv) != 1 || bv < 0) return false; c += 9; unsigned cv = 0; if (sscanf(c, "%x", &cv) != 1) return false; outBytes = (size_t)bv; outCrc = (uint32_t)cv; return true; } static bool fyValidateSessionFile(const char* path) { if (!SPIFFS.exists(path)) return false; File f = SPIFFS.open(path, "r"); if (!f) return false; String hdr = f.readStringUntil('\n'); if (hdr.length() < 10 || hdr[0] != '{') { f.close(); return false; } size_t expectedBytes = 0; uint32_t expectedCRC = 0; if (!fyParseEnvelope(hdr.c_str(), expectedBytes, expectedCRC)) { f.close(); return false; } size_t bodyOffset = hdr.length() + 1; size_t fileSize = f.size(); if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; } if ((fileSize - bodyOffset) != expectedBytes) { f.close(); return false; } uint8_t buf[256]; uint32_t crc = 0; size_t remaining = expectedBytes; while (remaining > 0) { int n = f.read(buf, remaining < sizeof(buf) ? remaining : sizeof(buf)); if (n <= 0) break; crc = fyCRC32Update(crc, buf, (size_t)n); remaining -= (size_t)n; } f.close(); return (remaining == 0 && crc == expectedCRC); } static bool fySpiffsCopy(const char* src, const char* dst) { File s = SPIFFS.open(src, "r"); if (!s) return false; File d = SPIFFS.open(dst, "w"); if (!d) { s.close(); return false; } uint8_t buf[256]; int n; bool ok = true; while ((n = s.read(buf, sizeof(buf))) > 0) { if (d.write(buf, (size_t)n) != (size_t)n) { ok = false; break; } } s.close(); d.close(); return ok; } static bool fyAtomicPromote(const char* src, const char* dst) { if (SPIFFS.rename(src, dst)) return true; if (!fySpiffsCopy(src, dst)) return false; SPIFFS.remove(src); return true; } static void fySaveSession() { if (!fySpiffsReady) return; if (!fyDirty && fyDetCount == fyLastSaveCount) return; size_t payloadBytes = 0; uint32_t crc = fyComputePayloadCRC(payloadBytes); int savedCount = fyDetCount; File f = SPIFFS.open(FY_SESSION_TMP, "w"); if (!f) { dualPrintf("[flockyou] save failed: cannot open %s\n", FY_SESSION_TMP); return; } f.printf("{\"v\":1,\"count\":%d,\"bytes\":%u,\"crc\":\"0x%08lX\"}\n", savedCount, (unsigned)payloadBytes, (unsigned long)crc); char line[384]; size_t wrote = 0; f.write((uint8_t*)"[", 1); wrote++; for (int i = 0; i < fyDetCount; i++) { if (i > 0) { f.write((uint8_t*)",", 1); wrote++; } size_t n = fySerializeDet(fyDet[i], line, sizeof(line)); if (n == 0) continue; f.write((uint8_t*)line, n); wrote += n; } f.write((uint8_t*)"]", 1); wrote++; f.close(); if (wrote != payloadBytes) { dualPrintf("[flockyou] save WARNING: wrote %u expected %u — aborting\n", (unsigned)wrote, (unsigned)payloadBytes); return; } if (!fyValidateSessionFile(FY_SESSION_TMP)) { dualPrintf("[flockyou] save verify FAILED — old session preserved\n"); return; } SPIFFS.remove(FY_SESSION_FILE); if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) { dualPrintf("[flockyou] promote FAILED — data in %s for recovery\n", FY_SESSION_TMP); return; } fyLastSaveAt = millis(); fyLastSaveCount = savedCount; fyDirty = false; dualPrintf("[flockyou] session saved: %d det, %u bytes, crc=0x%08lX\n", savedCount, (unsigned)payloadBytes, (unsigned long)crc); } // Promote any valid session file from last boot into /prev_session.json, then // start this boot with a fresh empty table. Preserves history across power cycles. static void fyPromotePrevSession() { if (!fySpiffsReady) return; const char* source = nullptr; if (fyValidateSessionFile(FY_SESSION_FILE)) source = FY_SESSION_FILE; else if (fyValidateSessionFile(FY_SESSION_TMP)) source = FY_SESSION_TMP; if (!source) { if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); dualPrintln("[flockyou] no valid prior session to promote"); return; } if (!fySpiffsCopy(source, FY_PREV_FILE)) { dualPrintf("[flockyou] failed to promote %s → %s\n", source, FY_PREV_FILE); return; } if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); File v = SPIFFS.open(FY_PREV_FILE, "r"); size_t sz = v ? v.size() : 0; if (v) v.close(); dualPrintf("[flockyou] prior session promoted from %s (%u bytes)\n", source, (unsigned)sz); } // ============================================================ // FLASK-COMPATIBLE JSON EMISSION // ============================================================ // // The Flask app (flock-you/api/flockyou.py) reads one JSON object per line // from the USB CDC serial port. It filters by presence of `detection_method` // and extracts these fields: mac_address, rssi, channel, frequency, ssid, // device_name, gps.latitude, gps.longitude, gps.accuracy. // // GPS is handled Flask-side via its own USB NMEA puck or browser geolocation; // we don't embed GPS here because there's no on-device AP / phone link. static void emitDetectionJSON(const char* mac, const char* method, int8_t rssi, uint8_t ch, const char* ssid) { char ssidEsc[sizeof(((FYDetection*)0)->ssid) * 6 + 1]; jsonEscape(ssidEsc, sizeof(ssidEsc), ssid ? ssid : ""); char oui[9]; uint8_t mbytes[6] = {0}; sscanf(mac, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", &mbytes[0], &mbytes[1], &mbytes[2], &mbytes[3], &mbytes[4], &mbytes[5]); ouiFromMac(mbytes, oui, sizeof(oui)); dualPrintf( "{\"event\":\"detection\"," "\"detection_method\":\"wifi_%s\"," "\"protocol\":\"wifi_2_4ghz\"," "\"mac_address\":\"%s\"," "\"oui\":\"%s\"," "\"device_name\":\"\"," "\"rssi\":%d," "\"channel\":%u," "\"frequency\":%u," "\"ssid\":\"%s\"}\n", method, mac, oui, rssi, (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc); } // Replay emission — used for both live-table dumps and SPIFFS-backed // historical dumps. Same Flask JSON shape as live detections, but flagged // with "replay":true and the source ("live"|"prev") plus the device's // monotonic millis() snapshots so the host can decide how to present them. static void emitReplayDetection(const char* mac, const char* method, int8_t rssi, uint8_t ch, const char* ssid, uint16_t count, uint32_t firstMs, uint32_t lastMs, const char* source) { char ssidEsc[sizeof(((FYDetection*)0)->ssid) * 6 + 1]; jsonEscape(ssidEsc, sizeof(ssidEsc), ssid ? ssid : ""); char oui[9]; uint8_t mbytes[6] = {0}; sscanf(mac, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", &mbytes[0], &mbytes[1], &mbytes[2], &mbytes[3], &mbytes[4], &mbytes[5]); ouiFromMac(mbytes, oui, sizeof(oui)); dualPrintf( "{\"event\":\"detection\"," "\"replay\":true," "\"replay_source\":\"%s\"," "\"detection_method\":\"wifi_%s\"," "\"protocol\":\"wifi_2_4ghz\"," "\"mac_address\":\"%s\"," "\"oui\":\"%s\"," "\"device_name\":\"\"," "\"rssi\":%d," "\"channel\":%u," "\"frequency\":%u," "\"ssid\":\"%s\"," "\"detection_count\":%u," "\"device_first_ms\":%lu," "\"device_last_ms\":%lu}\n", source, method, mac, oui, rssi, (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc, (unsigned)count, (unsigned long)firstMs, (unsigned long)lastMs); } // ============================================================ // HOST COMMAND INTERFACE (Flask ↔ firmware over USB-CDC) // ============================================================ // // Line-based protocol. The host writes one ASCII command per line // terminated by \n (or \r\n). The firmware replies with one or more JSON // objects, each on its own line, in the same `{"event":...}` schema the // Flask reader already understands. // // Commands: // CMD:STATUS → emits one {"event":"status",...} line // CMD:DUMP_LIVE → streams the current in-RAM detection table as // replay-detection lines, then a replay_complete // sentinel with source="live" // CMD:DUMP_PREV → same, but reads /prev_session.json from SPIFFS // (the previous boot's persisted session) // CMD:CLEAR_LIVE → wipes the in-RAM detection table // CMD:CLEAR_PREV → deletes /prev_session.json (and any /session.tmp) // CMD:VERSION → emits {"event":"version",...} // // Commands are case-sensitive. Unknown commands emit an "error" event. // Lines longer than CMD_BUF_SIZE-1 are silently truncated at the boundary. #define CMD_BUF_SIZE 80 #define REPLAY_OBJ_CAP 384 // generous: longest serialized entry ~330 B static char cmdBuf[CMD_BUF_SIZE]; static size_t cmdLen = 0; // Find the start of `"":` inside a flat JSON object string. // Returns pointer to the byte after the closing `:` (i.e. start of the value), // or null. The caller must skip whitespace. static const char* jsonValueStart(const char* obj, const char* key) { char pat[24]; int n = snprintf(pat, sizeof(pat), "\"%s\":", key); if (n <= 0 || (size_t)n >= sizeof(pat)) return nullptr; const char* p = strstr(obj, pat); if (!p) return nullptr; return p + n; } // Copy the contents of a JSON string field into dst (un-escaped). // Returns false if the field isn't a string or doesn't exist. static bool jsonGetString(const char* obj, const char* key, char* dst, size_t cap) { const char* p = jsonValueStart(obj, key); if (!p) return false; while (*p == ' ' || *p == '\t') p++; if (*p != '"') return false; p++; size_t out = 0; bool esc = false; while (*p && out < cap - 1) { if (esc) { dst[out++] = *p++; esc = false; } else if (*p == '\\') { esc = true; p++; } else if (*p == '"') { break; } else { dst[out++] = *p++; } } dst[out] = '\0'; return true; } static bool jsonGetInt(const char* obj, const char* key, long* out) { const char* p = jsonValueStart(obj, key); if (!p) return false; while (*p == ' ' || *p == '\t') p++; char* endp = nullptr; long v = strtol(p, &endp, 10); if (endp == p) return false; *out = v; return true; } // Stream-read one top-level `{...}` JSON object from `f` into `buf`. // Skips whitespace, commas, and the array `[`. Returns false on `]`, EOF, // or malformed input. String-aware brace counting handles `{`/`}` inside // SSID values (the writer doesn't escape those). static bool readNextJSONObject(File& f, char* buf, size_t cap) { int c; while ((c = f.read()) >= 0) { if (c == '{') break; if (c == ']') return false; } if (c != '{') return false; size_t pos = 0; buf[pos++] = '{'; int depth = 1; bool in_str = false; bool esc = false; while ((c = f.read()) >= 0) { if (pos >= cap - 1) return false; buf[pos++] = (char)c; if (esc) { esc = false; continue; } if (in_str) { if (c == '\\') esc = true; else if (c == '"') in_str = false; } else { if (c == '"') in_str = true; else if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { buf[pos] = '\0'; return true; } } } } return false; } static void cmdEmitStatus() { size_t prevSize = 0; bool prevExists = false; if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) { prevExists = true; File v = SPIFFS.open(FY_PREV_FILE, "r"); if (v) { prevSize = v.size(); v.close(); } } dualPrintf( "{\"event\":\"status\"," "\"fy_det\":%d," "\"oui_count\":%u," "\"spiffs\":%d," "\"prev_session\":%d," "\"prev_bytes\":%u," "\"uptime_ms\":%lu," "\"free_heap\":%u," "\"channel\":%u," "\"channel_mode\":\"%s\"," "\"rssi_min\":%d}\n", fyDetCount, (unsigned)OUI_COUNT, fySpiffsReady ? 1 : 0, prevExists ? 1 : 0, (unsigned)prevSize, (unsigned long)millis(), (unsigned)ESP.getFreeHeap(), (unsigned)currentChannel, channelModeName(), RSSI_MIN); } static void cmdEmitVersion() { dualPrintf( "{\"event\":\"version\"," "\"firmware\":\"flock-you-promiscious\"," "\"branch\":\"promiscious\"," "\"oui_count\":%u," "\"max_detections\":%d," "\"autosave_ms\":%lu}\n", (unsigned)OUI_COUNT, MAX_DETECTIONS, (unsigned long)AUTOSAVE_INTERVAL_MS); } static int cmdDumpLive() { int n = 0; for (int i = 0; i < fyDetCount; i++) { const FYDetection& d = fyDet[i]; emitReplayDetection(d.mac, d.method, d.rssi, d.channel, d.ssid, d.count, d.firstSeen, d.lastSeen, "live"); n++; } return n; } static int cmdDumpPrev() { if (!fySpiffsReady) return -2; if (!SPIFFS.exists(FY_PREV_FILE)) return -1; if (!fyValidateSessionFile(FY_PREV_FILE)) return -3; File f = SPIFFS.open(FY_PREV_FILE, "r"); if (!f) return -4; // Discard envelope header line; the array starts on line 2. f.readStringUntil('\n'); char obj[REPLAY_OBJ_CAP]; int n = 0; while (readNextJSONObject(f, obj, sizeof(obj))) { char mac[18] = {0}; char method[16]= {0}; char ssid[33] = {0}; long rssi = 0, channel = 0, count = 1, firstMs = 0, lastMs = 0; if (!jsonGetString(obj, "mac", mac, sizeof(mac))) continue; if (!jsonGetString(obj, "method", method, sizeof(method))) continue; jsonGetInt(obj, "rssi", &rssi); jsonGetInt(obj, "channel", &channel); jsonGetInt(obj, "count", &count); jsonGetInt(obj, "first", &firstMs); jsonGetInt(obj, "last", &lastMs); jsonGetString(obj, "ssid", ssid, sizeof(ssid)); if (rssi < -128) rssi = -128; else if (rssi > 127) rssi = 127; if (channel < 0) channel = 0; else if (channel > 255) channel = 255; if (count < 0) count = 0; else if (count > 0xFFFF) count = 0xFFFF; emitReplayDetection(mac, method, (int8_t)rssi, (uint8_t)channel, ssid, (uint16_t)count, (uint32_t)firstMs, (uint32_t)lastMs, "prev"); n++; } f.close(); return n; } static void cmdClearLive() { fyDetCount = 0; fyDirty = true; // force the next autosave to overwrite the file dualPrintf("{\"event\":\"clear\",\"target\":\"live\",\"ok\":true}\n"); } static void cmdClearPrev() { bool ok = false; if (fySpiffsReady) { if (SPIFFS.exists(FY_PREV_FILE)) ok = SPIFFS.remove(FY_PREV_FILE) || ok; // Also sweep any stray /session.tmp left over from an aborted save. if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); if (!SPIFFS.exists(FY_PREV_FILE)) ok = true; } dualPrintf("{\"event\":\"clear\",\"target\":\"prev\",\"ok\":%s}\n", ok ? "true" : "false"); } static void handleCommand(const char* cmd) { if (strcmp(cmd, "CMD:STATUS") == 0) { cmdEmitStatus(); } else if (strcmp(cmd, "CMD:VERSION") == 0) { cmdEmitVersion(); } else if (strcmp(cmd, "CMD:DUMP_LIVE") == 0) { int n = cmdDumpLive(); dualPrintf("{\"event\":\"replay_complete\",\"source\":\"live\"," "\"count\":%d,\"ok\":true}\n", n); } else if (strcmp(cmd, "CMD:DUMP_PREV") == 0) { int n = cmdDumpPrev(); if (n >= 0) { dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\"," "\"count\":%d,\"ok\":true}\n", n); } else { const char* reason = (n == -1) ? "no_file" : (n == -2) ? "spiffs_down" : (n == -3) ? "crc_mismatch" : (n == -4) ? "open_failed" : "unknown"; dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\"," "\"count\":0,\"ok\":false,\"reason\":\"%s\"}\n", reason); } } else if (strcmp(cmd, "CMD:CLEAR_LIVE") == 0) { cmdClearLive(); } else if (strcmp(cmd, "CMD:CLEAR_PREV") == 0) { cmdClearPrev(); } else { char escCmd[CMD_BUF_SIZE * 2]; jsonEscape(escCmd, sizeof(escCmd), cmd); dualPrintf("{\"event\":\"error\",\"reason\":\"unknown_command\"," "\"cmd\":\"%s\"}\n", escCmd); } } static void serialCmdTick() { while (Serial.available() > 0) { int b = Serial.read(); if (b < 0) break; if (b == '\n' || b == '\r') { if (cmdLen > 0) { cmdBuf[cmdLen] = '\0'; handleCommand(cmdBuf); cmdLen = 0; } } else if (cmdLen < CMD_BUF_SIZE - 1) { cmdBuf[cmdLen++] = (char)b; } // Lines longer than CMD_BUF_SIZE-1 silently truncate; the closing // newline still flushes whatever fits and handleCommand sees garbage, // which gets rejected as "unknown_command". } } // ============================================================ // PROMISCUOUS CALLBACK — keep it fast, no Serial, no malloc // ============================================================ static bool IRAM_ATTR extractSsidFromMgmtBody(const uint8_t* body, int len, char* outSsid, size_t outLen) { if (!body || len <= 0 || !outSsid || outLen == 0) return false; while (len >= 2) { uint8_t id = body[0], elen = body[1]; if ((int)elen + 2 > len) break; if (id == 0) { size_t n = (elen < (outLen - 1)) ? elen : (outLen - 1); memcpy(outSsid, body + 2, n); outSsid[n] = '\0'; return true; } body += elen + 2; len -= elen + 2; } return false; } // Returns: // 1 = wildcard SSID IE found (tag 0, length 0) → Flock-style probe // 0 = SSID IE found, non-zero length → directed probe, not ours // -1 = no SSID IE found at all → caller should retry with // FCS-stripped length, then bail static int IRAM_ATTR isWildcardProbeIE(const uint8_t* body, int len) { if (!body || len < 2) return -1; while (len >= 2) { uint8_t id = body[0]; uint8_t elen = body[1]; if ((int)elen + 2 > len) break; if (id == 0) return (elen == 0) ? 1 : 0; body += elen + 2; len -= elen + 2; } return -1; } static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) { if (!buf || sniffingStopped) return; #if PROCESS_MGMT_FRAMES && PROCESS_DATA_FRAMES if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return; #elif PROCESS_MGMT_FRAMES if (type != WIFI_PKT_MGMT) return; #elif PROCESS_DATA_FRAMES if (type != WIFI_PKT_DATA) return; #else return; // nothing configured to process #endif wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf; if (pkt->rx_ctrl.sig_len < sizeof(wifi_ieee80211_mac_hdr_t)) return; wifi_ieee80211_mac_hdr_t* hdr = (wifi_ieee80211_mac_hdr_t*)pkt->payload; int8_t rssi = pkt->rx_ctrl.rssi; if (rssi < RSSI_MIN) return; uint8_t ch = (uint8_t)pkt->rx_ctrl.channel; // actual rx channel from driver // --- OUI check: addr2 (transmitter/source) --- // // For mgmt Probe Requests (type=0 subtype=4) from a matched OUI, tighten // to the DeFlockJoplin wildcard-probe signature: SSID IE (tag 0) length // must be zero. This reduces false positives dramatically (Michael's field // test: 11/12 true-positive with only 2 false-positives in Joplin). // // Non-probe frames from the same OUI still emit the broad ADDR2 alert. // See: https://github.com/DeflockJoplin/flock-you if (matchOuiRaw(hdr->addr2)) { bool emitted = false; if (type == WIFI_PKT_MGMT) { uint8_t fc0 = hdr->frame_ctrl & 0xFF; uint8_t ftype = (fc0 >> 2) & 0x03; uint8_t subtype = (fc0 >> 4) & 0x0F; if (ftype == 0 && subtype == 4) { // Probe Request int sigLen = (int)pkt->rx_ctrl.sig_len; int bodyLen = sigLen - (int)sizeof(wifi_ieee80211_mac_hdr_t); const uint8_t* body = pkt->payload + sizeof(wifi_ieee80211_mac_hdr_t); int r = (bodyLen > 0) ? isWildcardProbeIE(body, bodyLen) : -1; // FCS-trailer retry: only when the first parse found no SSID IE AT // ALL (-1). A found-but-nonzero (0) means legit directed probe; do // not retry — it would mis-classify. if (r == -1 && bodyLen > 4) r = isWildcardProbeIE(body, bodyLen - 4); if (r == 1) { enqueueAlert(ALERT_WILDCARD_PROBE, hdr->addr2, rssi, ch, nullptr, "probe_req"); emitted = true; } } } if (!emitted) { enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "addr2"); } } #if CHECK_ADDR1 // addr1 (receiver/destination): catches Flock STAs that appear only as the // dst of probe responses and data frames — never transmitting in the capture // window due to their burst-sleep duty cycle. Multicast guard is mandatory // here since addr1 is broadcast (ff:ff:ff:ff:ff:ff) in beacons/broadcasts. if (!isMulticast(hdr->addr1) && matchOuiRaw(hdr->addr1)) { enqueueAlert(ALERT_OUI_ADDR1, hdr->addr1, rssi, ch, nullptr, "addr1"); } #endif #if CHECK_ADDR3 // addr3 fallback: catches cases where addr2 is randomised but addr3 // carries the real BSSID OUI (management frames only). if (type == WIFI_PKT_MGMT && matchOuiRaw(hdr->addr3)) { enqueueAlert(ALERT_OUI_ADDR3, hdr->addr3, rssi, ch, nullptr, "addr3"); } #endif #if ENABLE_SSID_MATCH if (type == WIFI_PKT_MGMT) { uint8_t fc0 = hdr->frame_ctrl & 0xFF; uint8_t subtype = (fc0 >> 4) & 0x0F; uint8_t ftype = (fc0 >> 2) & 0x03; if (ftype == 0) { int sigLen = pkt->rx_ctrl.sig_len - 4; // strip 4-byte FCS if (sigLen < (int)sizeof(wifi_ieee80211_mac_hdr_t)) return; const uint8_t* mgmtBody = nullptr; int mgmtBodyLen = 0; const char* frameKind = nullptr; if (subtype == 8 || subtype == 5) { // Beacon / Probe Response: fixed params = 12 bytes after MAC hdr int off = sizeof(wifi_ieee80211_mac_hdr_t) + 12; if (sigLen > off) { frameKind = (subtype == 8) ? "beacon" : "probe_resp"; mgmtBody = pkt->payload + off; mgmtBodyLen = sigLen - off; } } else if (subtype == 4) { // Probe Request: IEs follow directly after MAC hdr int off = sizeof(wifi_ieee80211_mac_hdr_t); if (sigLen > off) { frameKind = "probe_req"; mgmtBody = pkt->payload + off; mgmtBodyLen = sigLen - off; } } if (mgmtBody && mgmtBodyLen > 0) { char ssid[33] = {0}; if (extractSsidFromMgmtBody(mgmtBody, mgmtBodyLen, ssid, sizeof(ssid))) { if (matchSsidKeyword(ssid)) { enqueueAlert(ALERT_SSID, hdr->addr2, rssi, ch, ssid, frameKind); } } } } } #endif } // ============================================================ // DRAIN QUEUE — called from loop(), safe to Serial.print here // ============================================================ static void drainAlertQueue() { while (true) { portENTER_CRITICAL(&queueMux); if (alertTail == alertHead) { portEXIT_CRITICAL(&queueMux); break; } AlertEntry e; memcpy(&e, (const void*)&alertQueue[alertTail], sizeof(AlertEntry)); alertTail = (alertTail + 1) % ALERT_QUEUE_SIZE; portEXIT_CRITICAL(&queueMux); char macStr[18]; macToStr(e.mac, macStr, sizeof(macStr)); const char* method = alertTypeToMethod(e.type); // Always update the on-device detection table (survives reboot via SPIFFS). // chirpWorthy = true for brand-new MACs AND for MACs rediscovered after // REDISCOVER_MS of silence (drove away and came back). bool chirpWorthy = false; int idx = fyAddDetection(macStr, method, e.rssi, e.channel, (e.type == ALERT_SSID) ? e.ssid : nullptr, &chirpWorthy); // Refresh the global "still around" timer for the heartbeat tick. // Done unconditionally so a device counts as active even when serial is // rate-limited (still audible via heartbeat, just quieter on the wire). fyLastTargetSeen = millis(); // Serial-rate-limit: suppress emit/beep/flash within ALERT_COOLDOWN_MS. if (shouldSuppressDuplicate(macStr)) continue; // Human-readable line (for serial terminal / mirror). char oui[9]; ouiFromMac(e.mac, oui, sizeof(oui)); if (e.type == ALERT_SSID) { dualPrintf("[flockyou] DETECT-SSID type=%s mac=%s ssid=\"%s\" rssi=%d ch=%u count=%d\n", e.frameKind, macStr, e.ssid, e.rssi, e.channel, (idx >= 0) ? (int)fyDet[idx].count : 0); } else { dualPrintf("[flockyou] DETECT-OUI mac=%s oui=%s rssi=%d ch=%u addr=%s count=%d\n", macStr, oui, e.rssi, e.channel, e.frameKind[0] ? e.frameKind : "addr2", (idx >= 0) ? (int)fyDet[idx].count : 0); } // Flask-compatible JSON line (parsed by api/flockyou.py over USB CDC). emitDetectionJSON(macStr, method, e.rssi, e.channel, (e.type == ALERT_SSID) ? e.ssid : ""); // Audio feedback: // - NEW MAC → two fast ascending beeps (clearly distinct sound) // - REPEAT → silent; the heartbeat tick covers continued presence // LED flashes on every emitted detection either way. if (chirpWorthy) { newDetectChirp(); // Reset the heartbeat phase so the first follow-up beep lands // HB_BEEP_INTERVAL_MS after the initial chirp, not mid-window. fyLastHeartbeatAt = millis(); } ledFlash(LED_FLASH_MS); #if STOP_ON_OUI_HIT if (e.type != ALERT_SSID) stopSniffing("OUI hit"); #endif #if STOP_ON_SSID_HIT if (e.type == ALERT_SSID) stopSniffing("SSID hit"); #endif } } // ============================================================ // AUTOSAVE // ============================================================ static void autosaveTick() { if (!fySpiffsReady || !fyDirty) return; if (millis() - fyLastSaveAt < AUTOSAVE_INTERVAL_MS) return; fySaveSession(); } // Heartbeat beep while at least one target was seen in the last // HB_DEVICE_ACTIVE_MS. Fires HB_BEEP_INTERVAL_MS apart. static void heartbeatTick() { if (fyLastTargetSeen == 0) return; // never seen one unsigned long now = millis(); if (now - fyLastTargetSeen > HB_DEVICE_ACTIVE_MS) return; // gone silent if (now - fyLastHeartbeatAt < HB_BEEP_INTERVAL_MS) return; // too soon heartbeatBeep(); fyLastHeartbeatAt = now; } // ============================================================ // SETUP / LOOP // ============================================================ void setup() { Serial.begin(115200); // Crucial for USB-optional operation: without this, Serial.write() will // block indefinitely on an ESP32-S3 USB-CDC port when no host is attached. Serial.setTxTimeoutMs(0); delay(300); #if MIRROR_SERIAL Serial1.begin(MIRROR_BAUD, SERIAL_8N1, -1, MIRROR_TX_PIN); // TX-only on GPIO43 #endif #if USE_BUZZER pinMode(BUZZER_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW); #endif #if USE_LED pinMode(LED_PIN, OUTPUT); ledSet(false); #endif startupBeep(); #if USE_LED ledFlash(200); #endif precompileOuis(); memset(dedupeTable, 0, sizeof(dedupeTable)); // SPIFFS — format on first boot if missing. Non-fatal if it fails. if (SPIFFS.begin(true)) { fySpiffsReady = true; dualPrintln("[flockyou] SPIFFS ready"); fyPromotePrevSession(); } else { dualPrintln("[flockyou] SPIFFS init FAILED — running without persistence"); } WiFi.mode(WIFI_MODE_NULL); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(&cfg); esp_wifi_set_storage(WIFI_STORAGE_RAM); esp_wifi_set_mode(WIFI_MODE_NULL); esp_wifi_start(); applyInitialChannel(); wifi_promiscuous_filter_t filt = { .filter_mask = 0 #if PROCESS_MGMT_FRAMES | WIFI_PROMIS_FILTER_MASK_MGMT #endif #if PROCESS_DATA_FRAMES | WIFI_PROMIS_FILTER_MASK_DATA #endif }; esp_wifi_set_promiscuous_filter(&filt); esp_wifi_set_promiscuous_rx_cb(&wifiSniffer); esp_wifi_set_promiscuous(true); dualPrintln("[flockyou] merged WiFi detector started"); dualPrintf("[flockyou] mode=%s dwell_ms=%u start_channel=%u rssi_min=%d spiffs=%d\n", channelModeName(), CHANNEL_DWELL_MS, currentChannel, RSSI_MIN, fySpiffsReady ? 1 : 0); lastHeartbeat = millis(); fyLastSaveAt = millis(); } void loop() { updateChannelMode(); drainAlertQueue(); // Serial.printf happens here, not in callback serialCmdTick(); // CMD:STATUS / CMD:DUMP_* / CMD:CLEAR_* over USB-CDC autosaveTick(); // periodic SPIFFS write if dirty heartbeatTick(); // audible beep-pair while a target is still in range ledTick(); // turn off LED after LED_FLASH_MS printHeartbeat(); delay(1); }