From dc909624ade509b800d342406f497b53666d14da Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 07:37:46 -0400 Subject: [PATCH 1/8] firmware: add promiscuis-flock-you WiFi detector Modded from @NitekryDPaul's promiscuous-mode firmware. Adds: - SPIFFS session persistence with atomic CRC-envelope writes - Flask-compatible JSON emission over USB CDC (ingested by api/flockyou.py) - Onboard LED flash and buzzer beep per detection - USB-optional operation (non-blocking Serial for standalone runs) - First 6 notes of SMB World 1-2 as boot melody - Prior-session promotion to /prev_session.json on boot 30-OUI target list and the addr1-receiver detection technique are @NitekryDPaul's research (see datasets/NitekryDPaul_wifi_ouis.md). --- promiscuis-flock-you/README.md | 220 +++++++ promiscuis-flock-you/main.cpp | 980 ++++++++++++++++++++++++++++ promiscuis-flock-you/partitions.csv | 5 + promiscuis-flock-you/platformio.ini | 20 + 4 files changed, 1225 insertions(+) create mode 100644 promiscuis-flock-you/README.md create mode 100644 promiscuis-flock-you/main.cpp create mode 100644 promiscuis-flock-you/partitions.csv create mode 100644 promiscuis-flock-you/platformio.ini diff --git a/promiscuis-flock-you/README.md b/promiscuis-flock-you/README.md new file mode 100644 index 0000000..10bf0c9 --- /dev/null +++ b/promiscuis-flock-you/README.md @@ -0,0 +1,220 @@ +# promiscuis-flock-you + +WiFi-side companion to [flock-you](https://github.com/colonelpanichacks/flock-you). Passively detects Flock Safety cameras, Raven gunshot detectors, and related surveillance infrastructure by sniffing 2.4 GHz management and data frames in promiscuous mode. Emits Flask-compatible JSON over USB for live dashboard ingestion, stores detections on-device in SPIFFS so it works standalone too. + +--- + +## Credits + +All OUI research, the promiscuous-mode detection strategy, and the original firmware this is modded from: **ØяĐöØцяöЪöяцฐ @NitekryDPaul**. The core discovery — that Flock stations with randomised transmitter addresses still show up as the *destination* of probe responses and data frames during their burst-sleep duty cycle — is his. The 30-OUI target list below is his research. This fork adds Flask-app integration and on-device persistence on top of his work. + +--- + +## What it does + +- Sets the WiFi radio to `WIFI_MODE_NULL` and enables promiscuous sniffing +- Hops channels (default 1 / 6 / 11, 350 ms dwell) +- Inspects every 802.11 management and data frame +- Flags any frame whose `addr2` (transmitter) **or** `addr1` (receiver) matches a known Flock / Raven / SoundThinking OUI +- Skips multicast (`addr1` broadcast frames) and randomised MACs (locally-administered bit set) before matching +- Beeps the buzzer and flashes the onboard LED on every new detection +- Emits one JSON line per detection over USB CDC in the exact schema the Flask dashboard expects +- Stores detections in SPIFFS with an atomic CRC-checked envelope, so nothing is lost across power cycles + +Runs with or without USB attached. No AP, no web server — the radio stays dedicated to sniffing, channel hopping is preserved. + +--- + +## Why `addr1` matters + +Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure often goes long windows without transmitting — it sleeps, wakes in bursts, uploads, sleeps again. During that silence it may still show up on the air as the **destination** of probe responses or data frames from nearby APs. + +Checking `addr1` (receiver) picks those up. It requires a multicast guard (`addr1` is `ff:ff:ff:ff:ff:ff` in beacons and broadcasts) and a randomised-MAC guard, both of which are done at the top of the match function. + +This is the key insight from @NitekryDPaul's research. + +--- + +## Hardware + +- **Board**: Seeed Studio XIAO ESP32-S3 +- **Buzzer**: piezo on GPIO3 +- **LED**: onboard user LED on GPIO21 (active low) +- **Serial mirror**: TX-only on GPIO43 at 115200 baud (for attaching a second logger or a secondary device) + +--- + +## OUI target list + +All lowercase, colon-separated. From @NitekryDPaul's research: + +``` +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 f8:a2:d6 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 +``` + +Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. + +--- + +## Architecture + +``` + [2.4GHz air] + │ + ▼ + wifiSniffer() ← IRAM promiscuous callback (WiFi task) + │ fast match, no Serial, no malloc + ▼ + alertQueue[32] ← lock-free ring buffer (ISR-safe mux) + │ + ▼ + drainAlertQueue() ← loop() context + │ + ├─► fyAddDetection() ← always, every hit + │ │ + │ ▼ + │ fyDet[200] ← unique-by-MAC table + │ │ + │ ▼ + │ autosaveTick() ← every 60s when dirty + │ │ + │ ▼ + │ fySaveSession() ← atomic CRC-envelope write to SPIFFS + │ + ├─► shouldSuppressDuplicate() ← 5s per-MAC cooldown + │ + └─► emitDetectionJSON() ← USB CDC line for Flask + buzzerBeep() + ledFlash() +``` + +The split between callback and loop is deliberate: the WiFi task has hard real-time constraints and can't call `Serial.print` or `malloc` safely. The callback writes only to the ring buffer; `loop()` does all the heavy work. + +--- + +## SPIFFS wire format + +File layout on flash (atomic, crash-safe): + +``` +Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"} +Line 2: [{"mac":"...","method":"...","rssi":...,...},...] +``` + +Save procedure: + +1. Compute CRC32 + byte count over the serialised payload +2. Write envelope header + payload to `/session.tmp` +3. Re-read and re-validate `/session.tmp` (CRC check) +4. Remove `/session.json` +5. Atomic rename `/session.tmp` → `/session.json` (copy+delete fallback) + +Boot recovery: + +1. If `/session.json` validates, promote it to `/prev_session.json` +2. Otherwise try `/session.tmp` (interrupted save) +3. Delete both working files, start with an empty live table +4. `/prev_session.json` stays around for inspection + +CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. + +--- + +## Flask dashboard integration + +This firmware emits the same JSON schema as the BLE `flock-you` firmware, so the Flask app (`api/flockyou.py` in [colonelpanichacks/flock-you](https://github.com/colonelpanichacks/flock-you)) ingests it with no changes. + +Per-detection JSON line: + +```json +{"event":"detection","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":""} +``` + +`detection_method` values: + +- `wifi_oui_addr2` — transmitter-address OUI match +- `wifi_oui_addr1` — receiver-address OUI match (the @NitekryDPaul technique) +- `wifi_oui_addr3` — BSSID-address OUI match (management frames only, disabled by default) +- `wifi_ssid` — SSID keyword match (disabled by default) + +### GPS wardriving + +No AP, no on-device GPS — GPS is handled Flask-side. Plug a USB NMEA puck into the host running Flask, or open the Flask dashboard in a phone browser and let it post browser-geolocation updates. Flask does a temporal match between the detection timestamp and the GPS timeline, and exports JSON / CSV / KML for Google Earth. + +### Running Flask + +```bash +cd flock-you/api +pip install -r requirements.txt +python flockyou.py +``` + +Open `http://localhost:5000`, connect your serial port from the UI, and detections start showing up live. + +--- + +## Build and flash + +PlatformIO config for the XIAO ESP32-S3: + +```ini +[env:xiao_esp32s3] +platform = espressif32@^6.3.0 +board = seeed_xiao_esp32s3 +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 + +build_flags = + -DCORE_DEBUG_LEVEL=0 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + +board_build.arduino.memory_type = qio_opi +board_build.partitions = partitions.csv +board_build.filesystem = spiffs +``` + +`partitions.csv` is included — 1.9 MB SPIFFS partition, 6 MB app. + +No extra libraries needed. `SPIFFS.h` ships with Arduino-ESP32 core. + +--- + +## Config cheatsheet (top of `main.cpp`) + +| Define | Default | Notes | +|---|---|---| +| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` | +| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop | +| `RSSI_MIN` | -95 | Drop frames weaker than this | +| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit | +| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique | +| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) | +| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` | +| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. | +| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) | +| `MAX_DETECTIONS` | 200 | On-device table cap | +| `AUTOSAVE_INTERVAL_MS` | 60000 | SPIFFS save cadence | +| `LED_PIN` | 21 | Onboard user LED | +| `BUZZER_PIN` | 3 | Piezo | + +--- + +## Standalone vs connected operation + +**Without USB:** device boots, beeps, starts scanning, stores every unique detection to SPIFFS, flashes the onboard LED on each hit. Plug in later, the prior session is sitting in `/prev_session.json`. + +**With USB + Flask running:** same thing, plus every detection streams live to the dashboard as a JSON line. Flask adds GPS (if configured) and deduplicates across MAC, building the wardriving map as you move. + +Both modes work simultaneously — the SPIFFS write path doesn't care if a host is listening. + +--- + +## Legal / intended use + +Passive reception of publicly-broadcast 802.11 frames and public BLE advertisements. Privacy research, surveillance auditing, education. The device does not transmit and does not attempt to authenticate to any network. diff --git a/promiscuis-flock-you/main.cpp b/promiscuis-flock-you/main.cpp new file mode 100644 index 0000000..b6585fc --- /dev/null +++ b/promiscuis-flock-you/main.cpp @@ -0,0 +1,980 @@ +#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 + +#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[] = { + "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", "f8:a2:d6", "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" + +}; +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, +} 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; + +// ============================================================ +// 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) +static char _dualBuf[384]; + +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 +} +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"; + default: return "unknown"; + } +} + +// Returns index of entry (new or updated), or -1 if table is full. +static int fyAddDetection(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid) { + for (int i = 0; i < fyDetCount; i++) { + if (strcasecmp(fyDet[i].mac, mac) == 0) { + if (fyDet[i].count < 0xFFFF) fyDet[i].count++; + fyDet[i].lastSeen = millis(); + 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; + return i; + } + } + if (fyDetCount >= MAX_DETECTIONS) 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 = millis(); + d.lastSeen = d.firstSeen; + d.count = 1; + if (ssid && ssid[0]) strlcpy(d.ssid, ssid, sizeof(d.ssid)); + else d.ssid[0] = '\0'; + fyDetCount++; + fyDirty = 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); +} + +// ============================================================ +// 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; +} + +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) --- + if (matchOuiRaw(hdr->addr2)) { + 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). + int idx = fyAddDetection(macStr, method, e.rssi, e.channel, + (e.type == ALERT_SSID) ? e.ssid : nullptr); + + // 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 : ""); + + // Beep + LED flash on every emitted detection. + buzzerBeep(60); + 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(); +} + +// ============================================================ +// 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 + autosaveTick(); // periodic SPIFFS write if dirty + ledTick(); // turn off LED after LED_FLASH_MS + printHeartbeat(); + delay(1); +} diff --git a/promiscuis-flock-you/partitions.csv b/promiscuis-flock-you/partitions.csv new file mode 100644 index 0000000..b3ec3c3 --- /dev/null +++ b/promiscuis-flock-you/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x600000, +spiffs, data, spiffs, 0x610000, 0x1F0000, diff --git a/promiscuis-flock-you/platformio.ini b/promiscuis-flock-you/platformio.ini new file mode 100644 index 0000000..2b3da17 --- /dev/null +++ b/promiscuis-flock-you/platformio.ini @@ -0,0 +1,20 @@ +[platformio] +src_dir = . + +[env:xiao_esp32s3] +platform = espressif32@^6.3.0 +board = seeed_xiao_esp32s3 +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 + +build_flags = + -DCORE_DEBUG_LEVEL=0 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + +build_src_filter = + + +board_build.arduino.memory_type = qio_opi +board_build.partitions = partitions.csv +board_build.filesystem = spiffs From b6068057667b6e6383ee23de399cd59d9682ca26 Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 07:40:29 -0400 Subject: [PATCH 2/8] readme: document promiscuous WiFi companion on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains how the new WiFi promiscuous firmware in promiscuis-flock-you/ complements the existing BLE detector — same hardware class, same Flask dashboard schema, complementary RF coverage. Full research credit to ØяĐöØцяöЪöяцฐ / @NitekryDPaul for the 30-OUI target list and the addr1-receiver detection technique. Added to Acknowledgments. --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 7526737..bf9f1de 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,40 @@ No WiFi sniffing — the radio is dedicated to serving the dashboard AP while BL --- +## What's on this branch (`promiscious`) + +This branch adds a **WiFi sibling** to the BLE detector in a new `promiscuis-flock-you/` subdirectory. Same hardware class (XIAO ESP32-S3), same Flask dashboard, complementary RF coverage. + +| | BLE detector (`src/main.cpp`) | WiFi promiscuous detector (`promiscuis-flock-you/main.cpp`) | +|---|---|---| +| Radio | 2.4 GHz BLE scan | 2.4 GHz 802.11 promiscuous sniff | +| Targets | Flock / Raven BLE fingerprints | Flock Safety WiFi infrastructure OUIs | +| Dashboard | Hosts own AP + web UI at `192.168.4.1` | No AP — emits Flask JSON only | +| GPS | Phone geolocation via on-device AP | Flask-side (USB NMEA / browser) | +| Persistence | SPIFFS session file | SPIFFS session file (same envelope+CRC format) | +| Coverage | BLE-advertising Flock gear | Flock infrastructure seen on air, including stations silent on the transmitter-side due to burst-sleep duty cycles | + +Both firmwares emit the same Flask-compatible JSON schema over USB, so `api/flockyou.py` ingests them interchangeably. Run one, the other, or both in parallel on the same host — you get a merged detection map. + +### WiFi firmware highlights + +- **Promiscuous-mode sniff** on channels 1 / 6 / 11 with 350 ms dwell (configurable) +- **`addr1` + `addr2` matching** — the receiver-side check catches Flock stations that are silent on the transmitter side during their burst-sleep windows +- **Randomised-MAC and multicast guards** applied before OUI match to eliminate false positives +- **30-OUI target list** for Flock Safety infrastructure +- **SPIFFS persistence** with atomic CRC-envelope writes, `/prev_session.json` promotion on boot +- **Onboard LED flash + buzzer beep** per detection +- **Boot melody** — first 6 notes of SMB World 1-2 underground +- **USB-optional** — standalone operation with non-blocking Serial TX + +See [`promiscuis-flock-you/README.md`](promiscuis-flock-you/README.md) for the full walkthrough. + +### Research credit + +All WiFi promiscuous research — the 30-OUI target list and the addr1-receiver detection technique — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware on this branch is a mod of his original promiscuous-mode firmware with added SPIFFS persistence and Flask-dashboard integration. Full attribution and methodology in [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). + +--- + ## Detection Methods All detection is BLE-based: @@ -130,6 +164,7 @@ Firmware version is estimated automatically from which service UUIDs are adverti ## Acknowledgments +- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: 30-OUI Flock Safety target list and the addr1-receiver detection technique that form the `promiscuis-flock-you` firmware on this branch. See `promiscuis-flock-you/` and `datasets/NitekryDPaul_wifi_ouis.md`. The WiFi firmware here is a mod of his original promiscuous-mode firmware. - **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork - **[DeFlock](https://deflock.me)** ([FoggedLens/deflock](https://github.com/FoggedLens/deflock)) — crowdsourced ALPR location data and detection methodologies. Datasets included in `datasets/` - **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) enabling detection of SoundThinking/ShotSpotter acoustic surveillance devices From 54ef193e6b134eb7f610a036897ad8f32a9568d7 Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 07:44:34 -0400 Subject: [PATCH 3/8] =?UTF-8?q?readme:=20rewrite=20for=20promiscuous=20WiF?= =?UTF-8?q?i=20=E2=80=94=20branch=20focus=20is=20the=20WiFi=20firmware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'promiscious' branch is about the WiFi promiscuous detector, not BLE. Reworked the root README to lead with: - @NitekryDPaul credit front-and-center (30-OUI list + addr1 technique) - What the WiFi firmware does and why promiscuous mode is the right tool - Detection pipeline, OUI list, SPIFFS envelope format - Flask dashboard integration with the wifi_oui_addr1/addr2 methods - Hardware, build, config cheatsheet - SMB 1-2 underground boot melody The BLE firmware is now a short pointer to main at the bottom. --- README.md | 268 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 165 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index bf9f1de..55478a9 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,161 @@ -# Flock-You: Surveillance Device Detector +# Flock-You: Promiscuous WiFi Edition (`promiscious` branch) Flock You -**Standalone BLE surveillance device detector with web dashboard, GPS wardriving, and session persistence.** - -Available as part of the OUI-SPY project at [colonelpanic.tech](https://colonelpanic.tech) +**Passive 2.4 GHz promiscuous-mode detector for Flock Safety surveillance infrastructure. Runs standalone or feeds the Flask dashboard over USB for live GPS-tagged wardriving.** --- -## Overview +## Credit -Flock-You detects Flock Safety surveillance cameras, Raven gunshot detectors, and related monitoring hardware using BLE-only heuristics. It runs a WiFi access point with a live web dashboard on your phone, tags detections with GPS from your phone's browser, and exports everything as JSON, CSV, or KML for Google Earth. - -No WiFi sniffing — the radio is dedicated to serving the dashboard AP while BLE scans continuously in the background via ESP32 coexistence. +All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware in `promiscuis-flock-you/` is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). --- -## What's on this branch (`promiscious`) +## What this branch does -This branch adds a **WiFi sibling** to the BLE detector in a new `promiscuis-flock-you/` subdirectory. Same hardware class (XIAO ESP32-S3), same Flask dashboard, complementary RF coverage. +Turns a Seeed XIAO ESP32-S3 into a passive WiFi receiver that watches 2.4 GHz management and data frames for Flock Safety MAC OUIs. No AP, no transmit — the radio stays dedicated to sniffing while the device hops channels 1 / 6 / 11 at 350 ms dwell. -| | BLE detector (`src/main.cpp`) | WiFi promiscuous detector (`promiscuis-flock-you/main.cpp`) | -|---|---|---| -| Radio | 2.4 GHz BLE scan | 2.4 GHz 802.11 promiscuous sniff | -| Targets | Flock / Raven BLE fingerprints | Flock Safety WiFi infrastructure OUIs | -| Dashboard | Hosts own AP + web UI at `192.168.4.1` | No AP — emits Flask JSON only | -| GPS | Phone geolocation via on-device AP | Flask-side (USB NMEA / browser) | -| Persistence | SPIFFS session file | SPIFFS session file (same envelope+CRC format) | -| Coverage | BLE-advertising Flock gear | Flock infrastructure seen on air, including stations silent on the transmitter-side due to burst-sleep duty cycles | +Every detection is: -Both firmwares emit the same Flask-compatible JSON schema over USB, so `api/flockyou.py` ingests them interchangeably. Run one, the other, or both in parallel on the same host — you get a merged detection map. +- beeped (piezo on GPIO3) and flashed (onboard LED on GPIO21) +- written to on-device SPIFFS in an atomic CRC-envelope format, surviving power loss +- emitted as one JSON line over USB CDC in the schema `api/flockyou.py` expects, so the Flask dashboard auto-ingests it with GPS temporal matching -### WiFi firmware highlights - -- **Promiscuous-mode sniff** on channels 1 / 6 / 11 with 350 ms dwell (configurable) -- **`addr1` + `addr2` matching** — the receiver-side check catches Flock stations that are silent on the transmitter side during their burst-sleep windows -- **Randomised-MAC and multicast guards** applied before OUI match to eliminate false positives -- **30-OUI target list** for Flock Safety infrastructure -- **SPIFFS persistence** with atomic CRC-envelope writes, `/prev_session.json` promotion on boot -- **Onboard LED flash + buzzer beep** per detection -- **Boot melody** — first 6 notes of SMB World 1-2 underground -- **USB-optional** — standalone operation with non-blocking Serial TX - -See [`promiscuis-flock-you/README.md`](promiscuis-flock-you/README.md) for the full walkthrough. - -### Research credit - -All WiFi promiscuous research — the 30-OUI target list and the addr1-receiver detection technique — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware on this branch is a mod of his original promiscuous-mode firmware with added SPIFFS persistence and Flask-dashboard integration. Full attribution and methodology in [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). +The device works standalone (no USB host needed) and plugged in (live dashboard) without any mode switch. --- -## Detection Methods +## Why promiscuous mode, and why `addr1` -All detection is BLE-based: +Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure spends most of its duty cycle **asleep** — it wakes briefly in bursts, uploads, then sleeps again. During the silence it may never transmit a single frame in your capture window. -| Method | Description | -|--------|-------------| -| **MAC prefix** | 20 known Flock Safety OUI prefixes (FS Ext Battery, Flock WiFi modules) | -| **BLE device name** | Case-insensitive substring match: `FS Ext Battery`, `Penguin`, `Flock`, `Pigvision` | -| **Manufacturer ID** | `0x09C8` (XUNTONG) — catches devices with no broadcast name. *From [wgreenberg/flock-you](https://github.com/wgreenberg/flock-you)* | -| **Raven service UUID** | Identifies Raven gunshot detectors by BLE GATT service UUIDs | -| **Raven FW estimation** | Determines firmware version (1.1.x / 1.2.x / 1.3.x) from advertised service patterns | +But it may still appear on the air as the **destination** (`addr1`) of probe responses or data frames from nearby APs. + +Checking `addr1` in addition to `addr2` picks those silent stations up. It requires two guards to avoid false positives: + +- `addr1` is broadcast (`ff:ff:ff:ff:ff:ff`) in beacons and broadcasts — **multicast filter** +- Modern devices use randomised (locally-administered) MACs that can't be fingerprinted by OUI — **randomised-MAC filter** on byte 0 bit 1 + +Both are applied before the OUI match. This whole approach, including the 30-OUI list, is **@NitekryDPaul's research**. --- -## Features +## Detection pipeline -- **WiFi AP**: `flockyou` / password `flockyou123` -- **Web dashboard** at `192.168.4.1` — live detection feed, pattern database, export tools -- **GPS wardriving** — phone GPS via browser Geolocation API tags every detection with coordinates -- **Session persistence** — detections auto-save to flash (SPIFFS) every 60 seconds -- **Prior session tab** — previous session survives reboot and is viewable in the PREV tab -- **Export formats**: JSON, CSV, and KML (Google Earth) — current and prior sessions -- **Serial output** — Flask-compatible JSON over serial for live desktop ingestion -- **200 unique device storage** with FreeRTOS mutex thread safety -- **Crow call boot sounds** — modulated descending frequency sweeps with warble texture -- **Detection alerts** — ascending chirps + descending caw on new device detection -- **Heartbeat** — soft double coo every 10s while a device stays in range +``` + [2.4GHz air] + │ + ▼ + wifiSniffer() ← IRAM promiscuous callback (WiFi task) + │ fast match only, no Serial / no malloc + ▼ + alertQueue[32] ← lock-free ring buffer (ISR-safe mux) + │ + ▼ + drainAlertQueue() ← loop() context, per-iteration drain + │ + ├─► fyAddDetection() ← always, every hit + │ │ + │ ▼ + │ fyDet[200] ← unique-by-MAC on-device table + │ │ + │ ▼ + │ autosaveTick() ← every 60s when dirty + │ │ + │ ▼ + │ fySaveSession() ← atomic CRC-envelope write to SPIFFS + │ + ├─► shouldSuppressDuplicate() ← 5s per-MAC serial-emit rate limit + │ + └─► emitDetectionJSON() ← USB CDC line for Flask + buzzerBeep() + ledFlash() +``` + +The split between callback and loop is deliberate: the WiFi task has hard real-time constraints and cannot call `Serial.print` or `malloc` safely. The callback writes only to the lock-free ring buffer; `loop()` does all the heavy work. --- -## Enabling GPS (Android Chrome) +## OUI target list (@NitekryDPaul research) -The dashboard uses your phone's GPS to geotag detections. Because it's served over HTTP, Chrome requires a one-time flag change: +All lowercase, colon-separated. 30 Flock Safety infrastructure prefixes: -1. Open a new Chrome tab and go to `chrome://flags` -2. Search for **"Insecure origins treated as secure"** -3. Add `http://192.168.4.1` to the text field -4. Set the flag to **Enabled** -5. Tap **Relaunch** +``` +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 f8:a2:d6 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 +``` -After relaunching, connect to the `flockyou` AP, open `192.168.4.1`, and tap the **GPS** card in the stats bar to grant location permission. +Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. -> **Note:** iOS Safari does not support Geolocation over HTTP. GPS wardriving requires Android with Chrome. +Full dataset and methodology: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). + +--- + +## SPIFFS wire format + +On-flash layout, atomic and crash-safe: + +``` +Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"} +Line 2: [{"mac":"...","method":"...","rssi":...,...},...] +``` + +Save procedure: + +1. Compute CRC32 + byte count over the serialised payload +2. Write envelope header + payload to `/session.tmp` +3. Re-read and re-validate `/session.tmp` (CRC check) +4. Remove `/session.json` +5. Atomic rename `/session.tmp` → `/session.json` (copy+delete fallback) + +Boot recovery: + +1. If `/session.json` validates, promote it to `/prev_session.json` +2. Otherwise try `/session.tmp` (interrupted save) +3. Delete both working files, start with an empty live table +4. `/prev_session.json` stays around for inspection + +CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. + +--- + +## Flask dashboard integration + +The firmware emits one JSON line per detection in the same schema the BLE detector uses, so `api/flockyou.py` picks it up with zero changes: + +```json +{"event":"detection","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":""} +``` + +`detection_method` values: + +- `wifi_oui_addr2` — transmitter-side OUI match +- `wifi_oui_addr1` — **receiver-side OUI match** (the @NitekryDPaul technique) +- `wifi_oui_addr3` — BSSID OUI match (mgmt frames only; disabled by default) +- `wifi_ssid` — SSID keyword match (disabled by default) + +### GPS wardriving + +GPS is handled Flask-side, since the ESP32 radio is dedicated to sniffing and there's no on-device AP. Two options: + +- **USB NMEA puck** plugged into the host running Flask — Flask reads NMEA and timestamps a GPS timeline +- **Flask dashboard open in a phone browser** — browser Geolocation API posts updates to Flask + +Flask does a temporal match between detection timestamp and GPS timeline, then exports JSON / CSV / KML for Google Earth. + +### Running Flask + +```bash +cd api +pip install -r requirements.txt +python flockyou.py +``` + +Open `http://localhost:5000`, pick your serial port from the UI, detections start showing up live. --- @@ -103,71 +166,70 @@ After relaunching, connect to the `flockyou` AP, open `192.168.4.1`, and tap the | Pin | Function | |-----|----------| | GPIO 3 | Piezo buzzer | -| GPIO 21 | LED (optional) | +| GPIO 21 | Onboard user LED (active low) | +| GPIO 43 | Serial1 TX mirror (115200 baud) | + +Boot sound: first 6 notes of Super Mario Bros. World 1-2 (underground). --- -## Building & Flashing +## Build and flash Requires [PlatformIO](https://platformio.org/). ```bash -cd flock-you +cd promiscuis-flock-you pio run # build pio run -t upload # flash pio device monitor # serial output ``` -**Dependencies** (managed by PlatformIO): - -- `NimBLE-Arduino` — BLE scanning -- `ESP Async WebServer` + `AsyncTCP` — web dashboard -- `ArduinoJson` — JSON serialization -- `SPIFFS` — session persistence to flash +The subdirectory has its own `platformio.ini` and `partitions.csv` (1.9 MB SPIFFS partition, 6 MB app). No extra libraries needed beyond the Arduino-ESP32 core that ships with the espressif32 platform. --- -## Flask Companion App +## Config cheatsheet (top of `promiscuis-flock-you/main.cpp`) -The `api/` folder contains a Flask web application for desktop analysis of detection data. - -```bash -cd api -pip install -r requirements.txt -python flockyou.py -``` - -Open `http://localhost:5000` for the desktop dashboard. - -**Import support:** JSON, CSV, and KML files exported from the ESP32 can be imported directly into the Flask app. Live serial ingestion is also supported — connect the ESP32 via USB and select the serial port in the Flask UI. +| Define | Default | Notes | +|---|---|---| +| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` | +| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop | +| `RSSI_MIN` | -95 | Drop frames weaker than this | +| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit | +| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique | +| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) | +| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` | +| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. | +| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) | +| `MAX_DETECTIONS` | 200 | On-device table cap | +| `AUTOSAVE_INTERVAL_MS` | 60000 | SPIFFS save cadence | +| `LED_PIN` | 21 | Onboard user LED | +| `BUZZER_PIN` | 3 | Piezo | --- -## Raven Gunshot Detector Detection +## Standalone vs connected -Flock-You identifies SoundThinking/ShotSpotter Raven devices through BLE service UUID fingerprinting: +**Without USB:** device boots, plays the SMB 1-2 intro, starts scanning, stores every unique detection to SPIFFS, flashes the onboard LED on each hit. Plug in later — the prior session is sitting in `/prev_session.json`. -| Service | UUID | Description | -|---------|------|-------------| -| Device Info | `0000180a-...` | Serial, model, firmware | -| GPS | `00003100-...` | Real-time coordinates | -| Power | `00003200-...` | Battery & solar status | -| Network | `00003300-...` | LTE/WiFi connectivity | -| Upload | `00003400-...` | Data transmission metrics | -| Error | `00003500-...` | Diagnostics & error logs | -| Health (legacy) | `00001809-...` | Firmware 1.1.x | -| Location (legacy) | `00001819-...` | Firmware 1.1.x | +**With USB + Flask running:** same thing, plus every detection streams live to the dashboard as a JSON line. Flask adds GPS (if configured) and deduplicates across MAC, building the wardriving map as you move. -Firmware version is estimated automatically from which service UUIDs are advertised. +Both modes work simultaneously — the SPIFFS write path doesn't care if a host is listening. + +--- + +## BLE companion firmware (on `main` branch) + +The original BLE-only firmware still lives in `src/main.cpp`. It detects Flock and Raven gear via BLE advertisements (OUI prefix, device name, manufacturer ID `0x09C8`, Raven service UUIDs), runs its own WiFi AP with a phone-facing dashboard at `192.168.4.1`, and emits the same Flask JSON schema. Run both firmwares on separate boards for overlapping BLE + WiFi coverage feeding one dashboard. See the `main` branch README for BLE-specific details. --- ## Acknowledgments -- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: 30-OUI Flock Safety target list and the addr1-receiver detection technique that form the `promiscuis-flock-you` firmware on this branch. See `promiscuis-flock-you/` and `datasets/NitekryDPaul_wifi_ouis.md`. The WiFi firmware here is a mod of his original promiscuous-mode firmware. -- **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork +- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that form the entirety of the `promiscuis-flock-you` firmware on this branch. The firmware here is a mod of his original work. +- **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork (used by the BLE companion on `main`) - **[DeFlock](https://deflock.me)** ([FoggedLens/deflock](https://github.com/FoggedLens/deflock)) — crowdsourced ALPR location data and detection methodologies. Datasets included in `datasets/` -- **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) enabling detection of SoundThinking/ShotSpotter acoustic surveillance devices +- **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) used by the BLE companion --- @@ -197,4 +259,4 @@ Flock-You is part of the OUI-SPY firmware family: ## Disclaimer -This tool is intended for security research, privacy auditing, and educational purposes. Detecting the presence of surveillance hardware in public spaces is legal in most jurisdictions. Always comply with local laws regarding wireless scanning and signal interception. The authors are not responsible for misuse. +Passive reception of publicly-broadcast 802.11 frames for security research, privacy auditing, and education. The device does not transmit and does not authenticate to any network. Detecting the presence of surveillance hardware in public spaces is legal in most jurisdictions; always comply with local laws regarding wireless reception. From ec7c04a0df3cbf62c2675841e3c9676602417885 Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 07:47:14 -0400 Subject: [PATCH 4/8] flatten: promiscuous WiFi firmware is the branch content, not a subfolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved promiscuis-flock-you/{main.cpp,partitions.csv,platformio.ini} to root - Removed BLE firmware (src/main.cpp, src/CMakeLists.txt) — the 'main' branch still has it - Removed the subfolder README (root README has the full walkthrough) - Updated README paths and build commands for the flat layout - Retagged the BLE companion section as a pointer to the 'main' branch The 'promiscious' branch is now purely the WiFi promiscuous firmware plus the shared Flask app (api/), datasets, and branding. Builds green with the standard 'pio run' from the repo root. --- README.md | 13 +- promiscuis-flock-you/main.cpp => main.cpp | 0 platformio.ini | 20 +- promiscuis-flock-you/README.md | 220 --- promiscuis-flock-you/partitions.csv | 5 - promiscuis-flock-you/platformio.ini | 20 - src/CMakeLists.txt | 6 - src/main.cpp | 1476 --------------------- 8 files changed, 11 insertions(+), 1749 deletions(-) rename promiscuis-flock-you/main.cpp => main.cpp (100%) delete mode 100644 promiscuis-flock-you/README.md delete mode 100644 promiscuis-flock-you/partitions.csv delete mode 100644 promiscuis-flock-you/platformio.ini delete mode 100644 src/CMakeLists.txt delete mode 100644 src/main.cpp diff --git a/README.md b/README.md index 55478a9..0d8f971 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Credit -All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware in `promiscuis-flock-you/` is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). +All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware here is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). --- @@ -178,17 +178,16 @@ Boot sound: first 6 notes of Super Mario Bros. World 1-2 (underground). Requires [PlatformIO](https://platformio.org/). ```bash -cd promiscuis-flock-you pio run # build pio run -t upload # flash pio device monitor # serial output ``` -The subdirectory has its own `platformio.ini` and `partitions.csv` (1.9 MB SPIFFS partition, 6 MB app). No extra libraries needed beyond the Arduino-ESP32 core that ships with the espressif32 platform. +`platformio.ini` and `partitions.csv` are at the root (1.9 MB SPIFFS partition, 6 MB app). No extra libraries needed beyond the Arduino-ESP32 core that ships with the espressif32 platform. --- -## Config cheatsheet (top of `promiscuis-flock-you/main.cpp`) +## Config cheatsheet (top of `main.cpp`) | Define | Default | Notes | |---|---|---| @@ -218,15 +217,15 @@ Both modes work simultaneously — the SPIFFS write path doesn't care if a host --- -## BLE companion firmware (on `main` branch) +## BLE companion firmware -The original BLE-only firmware still lives in `src/main.cpp`. It detects Flock and Raven gear via BLE advertisements (OUI prefix, device name, manufacturer ID `0x09C8`, Raven service UUIDs), runs its own WiFi AP with a phone-facing dashboard at `192.168.4.1`, and emits the same Flask JSON schema. Run both firmwares on separate boards for overlapping BLE + WiFi coverage feeding one dashboard. See the `main` branch README for BLE-specific details. +The BLE-only sibling of this firmware lives on the [`main` branch](https://github.com/colonelpanichacks/flock-you/tree/main). It detects Flock and Raven gear via BLE advertisements (OUI prefix, device name, manufacturer ID `0x09C8`, Raven service UUIDs), runs its own WiFi AP with a phone-facing dashboard at `192.168.4.1`, and emits the same Flask JSON schema. Flash both on separate boards for overlapping BLE + WiFi coverage feeding one Flask dashboard. --- ## Acknowledgments -- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that form the entirety of the `promiscuis-flock-you` firmware on this branch. The firmware here is a mod of his original work. +- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that are the entirety of this firmware. The code here is a mod of his original work. - **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork (used by the BLE companion on `main`) - **[DeFlock](https://deflock.me)** ([FoggedLens/deflock](https://github.com/FoggedLens/deflock)) — crowdsourced ALPR location data and detection methodologies. Datasets included in `datasets/` - **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) used by the BLE companion diff --git a/promiscuis-flock-you/main.cpp b/main.cpp similarity index 100% rename from promiscuis-flock-you/main.cpp rename to main.cpp diff --git a/platformio.ini b/platformio.ini index 4bf3b22..2b3da17 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,6 @@ +[platformio] +src_dir = . + [env:xiao_esp32s3] platform = espressif32@^6.3.0 board = seeed_xiao_esp32s3 @@ -5,26 +8,13 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 -; Build options -build_flags = +build_flags = -DCORE_DEBUG_LEVEL=0 -DARDUINO_USB_CDC_ON_BOOT=1 -DBOARD_HAS_PSRAM - -mfix-esp32-psram-cache-issue - -DCONFIG_BT_NIMBLE_ENABLED=1 -; Libraries -lib_deps = - h2zero/NimBLE-Arduino@^1.4.0 - mathieucarbou/ESP Async WebServer@^3.0.6 - bblanchon/ArduinoJson@^7.0.4 +build_src_filter = + -; Board configuration board_build.arduino.memory_type = qio_opi board_build.partitions = partitions.csv board_build.filesystem = spiffs - -; USB CDC configuration -board_build.f_cpu = 240000000L -board_build.f_flash = 80000000L -board_build.flash_mode = qio diff --git a/promiscuis-flock-you/README.md b/promiscuis-flock-you/README.md deleted file mode 100644 index 10bf0c9..0000000 --- a/promiscuis-flock-you/README.md +++ /dev/null @@ -1,220 +0,0 @@ -# promiscuis-flock-you - -WiFi-side companion to [flock-you](https://github.com/colonelpanichacks/flock-you). Passively detects Flock Safety cameras, Raven gunshot detectors, and related surveillance infrastructure by sniffing 2.4 GHz management and data frames in promiscuous mode. Emits Flask-compatible JSON over USB for live dashboard ingestion, stores detections on-device in SPIFFS so it works standalone too. - ---- - -## Credits - -All OUI research, the promiscuous-mode detection strategy, and the original firmware this is modded from: **ØяĐöØцяöЪöяцฐ @NitekryDPaul**. The core discovery — that Flock stations with randomised transmitter addresses still show up as the *destination* of probe responses and data frames during their burst-sleep duty cycle — is his. The 30-OUI target list below is his research. This fork adds Flask-app integration and on-device persistence on top of his work. - ---- - -## What it does - -- Sets the WiFi radio to `WIFI_MODE_NULL` and enables promiscuous sniffing -- Hops channels (default 1 / 6 / 11, 350 ms dwell) -- Inspects every 802.11 management and data frame -- Flags any frame whose `addr2` (transmitter) **or** `addr1` (receiver) matches a known Flock / Raven / SoundThinking OUI -- Skips multicast (`addr1` broadcast frames) and randomised MACs (locally-administered bit set) before matching -- Beeps the buzzer and flashes the onboard LED on every new detection -- Emits one JSON line per detection over USB CDC in the exact schema the Flask dashboard expects -- Stores detections in SPIFFS with an atomic CRC-checked envelope, so nothing is lost across power cycles - -Runs with or without USB attached. No AP, no web server — the radio stays dedicated to sniffing, channel hopping is preserved. - ---- - -## Why `addr1` matters - -Most WiFi sniffers only check the transmitter address (`addr2`). Flock infrastructure often goes long windows without transmitting — it sleeps, wakes in bursts, uploads, sleeps again. During that silence it may still show up on the air as the **destination** of probe responses or data frames from nearby APs. - -Checking `addr1` (receiver) picks those up. It requires a multicast guard (`addr1` is `ff:ff:ff:ff:ff:ff` in beacons and broadcasts) and a randomised-MAC guard, both of which are done at the top of the match function. - -This is the key insight from @NitekryDPaul's research. - ---- - -## Hardware - -- **Board**: Seeed Studio XIAO ESP32-S3 -- **Buzzer**: piezo on GPIO3 -- **LED**: onboard user LED on GPIO21 (active low) -- **Serial mirror**: TX-only on GPIO43 at 115200 baud (for attaching a second logger or a secondary device) - ---- - -## OUI target list - -All lowercase, colon-separated. From @NitekryDPaul's research: - -``` -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 f8:a2:d6 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 -``` - -Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. - ---- - -## Architecture - -``` - [2.4GHz air] - │ - ▼ - wifiSniffer() ← IRAM promiscuous callback (WiFi task) - │ fast match, no Serial, no malloc - ▼ - alertQueue[32] ← lock-free ring buffer (ISR-safe mux) - │ - ▼ - drainAlertQueue() ← loop() context - │ - ├─► fyAddDetection() ← always, every hit - │ │ - │ ▼ - │ fyDet[200] ← unique-by-MAC table - │ │ - │ ▼ - │ autosaveTick() ← every 60s when dirty - │ │ - │ ▼ - │ fySaveSession() ← atomic CRC-envelope write to SPIFFS - │ - ├─► shouldSuppressDuplicate() ← 5s per-MAC cooldown - │ - └─► emitDetectionJSON() ← USB CDC line for Flask - buzzerBeep() + ledFlash() -``` - -The split between callback and loop is deliberate: the WiFi task has hard real-time constraints and can't call `Serial.print` or `malloc` safely. The callback writes only to the ring buffer; `loop()` does all the heavy work. - ---- - -## SPIFFS wire format - -File layout on flash (atomic, crash-safe): - -``` -Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"} -Line 2: [{"mac":"...","method":"...","rssi":...,...},...] -``` - -Save procedure: - -1. Compute CRC32 + byte count over the serialised payload -2. Write envelope header + payload to `/session.tmp` -3. Re-read and re-validate `/session.tmp` (CRC check) -4. Remove `/session.json` -5. Atomic rename `/session.tmp` → `/session.json` (copy+delete fallback) - -Boot recovery: - -1. If `/session.json` validates, promote it to `/prev_session.json` -2. Otherwise try `/session.tmp` (interrupted save) -3. Delete both working files, start with an empty live table -4. `/prev_session.json` stays around for inspection - -CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. - ---- - -## Flask dashboard integration - -This firmware emits the same JSON schema as the BLE `flock-you` firmware, so the Flask app (`api/flockyou.py` in [colonelpanichacks/flock-you](https://github.com/colonelpanichacks/flock-you)) ingests it with no changes. - -Per-detection JSON line: - -```json -{"event":"detection","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":""} -``` - -`detection_method` values: - -- `wifi_oui_addr2` — transmitter-address OUI match -- `wifi_oui_addr1` — receiver-address OUI match (the @NitekryDPaul technique) -- `wifi_oui_addr3` — BSSID-address OUI match (management frames only, disabled by default) -- `wifi_ssid` — SSID keyword match (disabled by default) - -### GPS wardriving - -No AP, no on-device GPS — GPS is handled Flask-side. Plug a USB NMEA puck into the host running Flask, or open the Flask dashboard in a phone browser and let it post browser-geolocation updates. Flask does a temporal match between the detection timestamp and the GPS timeline, and exports JSON / CSV / KML for Google Earth. - -### Running Flask - -```bash -cd flock-you/api -pip install -r requirements.txt -python flockyou.py -``` - -Open `http://localhost:5000`, connect your serial port from the UI, and detections start showing up live. - ---- - -## Build and flash - -PlatformIO config for the XIAO ESP32-S3: - -```ini -[env:xiao_esp32s3] -platform = espressif32@^6.3.0 -board = seeed_xiao_esp32s3 -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 - -build_flags = - -DCORE_DEBUG_LEVEL=0 - -DARDUINO_USB_CDC_ON_BOOT=1 - -DBOARD_HAS_PSRAM - -board_build.arduino.memory_type = qio_opi -board_build.partitions = partitions.csv -board_build.filesystem = spiffs -``` - -`partitions.csv` is included — 1.9 MB SPIFFS partition, 6 MB app. - -No extra libraries needed. `SPIFFS.h` ships with Arduino-ESP32 core. - ---- - -## Config cheatsheet (top of `main.cpp`) - -| Define | Default | Notes | -|---|---|---| -| `CHANNEL_MODE` | `CHANNEL_MODE_CUSTOM` | `CUSTOM` (1/6/11), `FULL_HOP` (1-11), or `SINGLE` | -| `CHANNEL_DWELL_MS` | 350 | Time on each channel before hop | -| `RSSI_MIN` | -95 | Drop frames weaker than this | -| `ALERT_COOLDOWN_MS` | 5000 | Per-MAC serial-emit rate limit | -| `CHECK_ADDR1` | 1 | The @NitekryDPaul receiver-side technique | -| `CHECK_ADDR3` | 0 | BSSID fallback (mgmt frames only) | -| `ENABLE_SSID_MATCH` | 0 | Substring match against `target_ssid_keywords[]` | -| `PROCESS_MGMT_FRAMES` | 1 | Beacons, probe req/resp, etc. | -| `PROCESS_DATA_FRAMES` | 1 | Data frames (where addr1 catch shines) | -| `MAX_DETECTIONS` | 200 | On-device table cap | -| `AUTOSAVE_INTERVAL_MS` | 60000 | SPIFFS save cadence | -| `LED_PIN` | 21 | Onboard user LED | -| `BUZZER_PIN` | 3 | Piezo | - ---- - -## Standalone vs connected operation - -**Without USB:** device boots, beeps, starts scanning, stores every unique detection to SPIFFS, flashes the onboard LED on each hit. Plug in later, the prior session is sitting in `/prev_session.json`. - -**With USB + Flask running:** same thing, plus every detection streams live to the dashboard as a JSON line. Flask adds GPS (if configured) and deduplicates across MAC, building the wardriving map as you move. - -Both modes work simultaneously — the SPIFFS write path doesn't care if a host is listening. - ---- - -## Legal / intended use - -Passive reception of publicly-broadcast 802.11 frames and public BLE advertisements. Privacy research, surveillance auditing, education. The device does not transmit and does not attempt to authenticate to any network. diff --git a/promiscuis-flock-you/partitions.csv b/promiscuis-flock-you/partitions.csv deleted file mode 100644 index b3ec3c3..0000000 --- a/promiscuis-flock-you/partitions.csv +++ /dev/null @@ -1,5 +0,0 @@ -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x5000, -otadata, data, ota, 0xe000, 0x2000, -app0, app, ota_0, 0x10000, 0x600000, -spiffs, data, spiffs, 0x610000, 0x1F0000, diff --git a/promiscuis-flock-you/platformio.ini b/promiscuis-flock-you/platformio.ini deleted file mode 100644 index 2b3da17..0000000 --- a/promiscuis-flock-you/platformio.ini +++ /dev/null @@ -1,20 +0,0 @@ -[platformio] -src_dir = . - -[env:xiao_esp32s3] -platform = espressif32@^6.3.0 -board = seeed_xiao_esp32s3 -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 - -build_flags = - -DCORE_DEBUG_LEVEL=0 - -DARDUINO_USB_CDC_ON_BOOT=1 - -DBOARD_HAS_PSRAM - -build_src_filter = + - -board_build.arduino.memory_type = qio_opi -board_build.partitions = partitions.csv -board_build.filesystem = spiffs diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt deleted file mode 100644 index 483bc0c..0000000 --- a/src/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -# This file was automatically generated for projects -# without default 'CMakeLists.txt' file. - -FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/src/*.*) - -idf_component_register(SRCS ${app_sources}) diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index d6bba1b..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,1476 +0,0 @@ -// ============================================================================ -// FLOCK-YOU: Surveillance Device Detector with Web Dashboard -// ============================================================================ -// Detection methods (BLE only - WiFi radio used for AP): -// 1. BLE MAC prefix matching (known Flock Safety OUIs) -// 2. BLE device name pattern matching (case-insensitive substring) -// 3. BLE manufacturer company ID matching (0x09C8 XUNTONG) [from wgreenberg] -// 4. Raven gunshot detector service UUID matching -// 5. Raven firmware version estimation from service UUID patterns -// -// WiFi AP "flockyou" / "flockyou123" serves web dashboard at 192.168.4.1 -// All detections stored in memory, exportable as JSON or CSV -// Optional WiFi STA connection for future features -// ============================================================================ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "esp_wifi.h" - -// ============================================================================ -// CONFIGURATION -// ============================================================================ - -#define BUZZER_PIN 3 - -// Audio -#define LOW_FREQ 200 -#define HIGH_FREQ 800 -#define DETECT_FREQ 1000 -#define HEARTBEAT_FREQ 600 -#define BOOT_BEEP_DURATION 300 -#define DETECT_BEEP_DURATION 150 -#define HEARTBEAT_DURATION 100 - -// BLE scanning (mutable — companion mode increases scan duty cycle) -static int fyBleScanDuration = 2; // seconds per scan -static unsigned long fyBleScanInterval = 3000; // ms between scans - -// Detection storage -#define MAX_DETECTIONS 200 - -// WiFi AP credentials -#define FY_AP_SSID "flockyou" -#define FY_AP_PASS "flockyou123" - -// ============================================================================ -// DETECTION PATTERNS -// ============================================================================ - -// MAC address prefixes (OUIs) - -// Flock Safety — high-confidence OUIs (direct registration or exclusive use) -static const char* flock_mac_prefixes[] = { - // FS Ext Battery devices - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - // Flock WiFi devices - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea", - // Flock Safety (direct IEEE registration) - "b4:1e:52" -}; - -// Flock Safety contract manufacturers — lower confidence alone. -// These OUIs belong to Liteon Technology and USI (Universal Scientific -// Industrial), which produce Flock hardware but also ship unrelated -// consumer/enterprise devices. MAC match alone may be a false positive. -static const char* flock_mfr_mac_prefixes[] = { - "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", - "e8:d0:fc" -}; - -// SoundThinking (formerly ShotSpotter) — acoustic gunshot detection sensors. -// d4:11:d6 is registered to SoundThinking in the IEEE OUI database. -static const char* soundthinking_mac_prefixes[] = { - "d4:11:d6" -}; - -// BLE device name patterns (matched case-insensitive substring) -static const char* device_name_patterns[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" -}; - -// BLE Manufacturer Company IDs -// Source: wgreenberg/flock-you - XUNTONG ID associated with Flock Safety devices -static const uint16_t ble_manufacturer_ids[] = { - 0x09C8 // XUNTONG -}; - -// ============================================================================ -// RAVEN SURVEILLANCE DEVICE UUID PATTERNS -// ============================================================================ - -#define RAVEN_DEVICE_INFO_SERVICE "0000180a-0000-1000-8000-00805f9b34fb" -#define RAVEN_GPS_SERVICE "00003100-0000-1000-8000-00805f9b34fb" -#define RAVEN_POWER_SERVICE "00003200-0000-1000-8000-00805f9b34fb" -#define RAVEN_NETWORK_SERVICE "00003300-0000-1000-8000-00805f9b34fb" -#define RAVEN_UPLOAD_SERVICE "00003400-0000-1000-8000-00805f9b34fb" -#define RAVEN_ERROR_SERVICE "00003500-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_HEALTH_SERVICE "00001809-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_LOCATION_SERVICE "00001819-0000-1000-8000-00805f9b34fb" - -static const char* raven_service_uuids[] = { - RAVEN_DEVICE_INFO_SERVICE, - RAVEN_GPS_SERVICE, - RAVEN_POWER_SERVICE, - RAVEN_NETWORK_SERVICE, - RAVEN_UPLOAD_SERVICE, - RAVEN_ERROR_SERVICE, - RAVEN_OLD_HEALTH_SERVICE, - RAVEN_OLD_LOCATION_SERVICE -}; - -// ============================================================================ -// DETECTION STORAGE -// ============================================================================ - -struct FYDetection { - char mac[18]; - char name[48]; - int rssi; - char method[32]; - unsigned long firstSeen; - unsigned long lastSeen; - int count; - bool isRaven; - char ravenFW[16]; - // GPS from phone (wardriving) - double gpsLat; - double gpsLon; - float gpsAcc; - bool hasGPS; -}; - -static FYDetection fyDet[MAX_DETECTIONS]; -static int fyDetCount = 0; -static SemaphoreHandle_t fyMutex = NULL; // guards fyDet[] + fyDetCount -static SemaphoreHandle_t fyGPSMutex = NULL; // guards fyGPS* globals - -// ============================================================================ -// GLOBALS -// ============================================================================ - -static bool fyBuzzerOn = true; -static unsigned long fyLastBleScan = 0; -static bool fyTriggered = false; -static bool fyDeviceInRange = false; -static unsigned long fyLastDetTime = 0; -static unsigned long fyLastHB = 0; -static NimBLEScan* fyBLEScan = NULL; -static AsyncWebServer fyServer(80); - -// BLE GATT server (DeFlock app connectivity) -#define FY_SERVICE_UUID "a1b2c3d4-e5f6-7890-abcd-ef0123456789" -#define FY_TX_CHAR_UUID "a1b2c3d4-e5f6-7890-abcd-ef01234567aa" -static NimBLEServer* fyBLEServer = NULL; -static NimBLECharacteristic* fyTxChar = NULL; -static volatile bool fyBLEClientConnected = false; -static volatile uint16_t fyNegotiatedMTU = 23; - -// Serial host detection (USB heartbeat from DeFlock desktop) -static volatile bool fySerialHostConnected = false; -static unsigned long fyLastSerialHeartbeat = 0; -#define FY_SERIAL_TIMEOUT_MS 5000 - -// Deferred companion mode switch — BLE callbacks set this flag, -// loop() applies the WiFi/scan changes in the Arduino task context. -static volatile bool fyCompanionChangePending = false; - -// Phone GPS state (updated via browser Geolocation API -> /api/gps) -static double fyGPSLat = 0; -static double fyGPSLon = 0; -static float fyGPSAcc = 0; -static bool fyGPSValid = false; -static unsigned long fyGPSLastUpdate = 0; -#define GPS_STALE_MS 30000 // GPS considered stale after 30s without update - -// Session persistence (SPIFFS) -#define FY_SESSION_FILE "/session.json" -#define FY_PREV_FILE "/prev_session.json" -#define FY_SAVE_INTERVAL 15000 // Auto-save every 15 seconds (prevent data loss on quick power-cycle) -static unsigned long fyLastSave = 0; -static int fyLastSaveCount = 0; // Track changes to avoid unnecessary writes -static bool fySpiffsReady = false; - -// ============================================================================ -// AUDIO SYSTEM -// ============================================================================ - -static void fyBeep(int freq, int dur) { - if (!fyBuzzerOn) return; - tone(BUZZER_PIN, freq, dur); - delay(dur + 50); -} - -// Crow caw: harsh descending sweep with warble texture -static void fyCaw(int startFreq, int endFreq, int durationMs, int warbleHz) { - if (!fyBuzzerOn) return; - int steps = durationMs / 8; // 8ms per step - float fStep = (float)(endFreq - startFreq) / steps; - for (int i = 0; i < steps; i++) { - int f = startFreq + (int)(fStep * i); - // Add warble: oscillate frequency +/- for raspy texture - if (warbleHz > 0 && (i % 3 == 0)) { - f += ((i % 6 < 3) ? warbleHz : -warbleHz); - } - if (f < 100) f = 100; - tone(BUZZER_PIN, f, 10); - delay(8); - } - noTone(BUZZER_PIN); -} - -static void fyBootBeep() { - printf("[FLOCK-YOU] Boot sound (buzzer %s)\n", fyBuzzerOn ? "ON" : "OFF"); - if (!fyBuzzerOn) return; - - // === CROW CALL SEQUENCE === - // Caw 1: sharp descending caw - fyCaw(850, 380, 180, 40); - delay(100); - - // Caw 2: slightly lower, shorter - fyCaw(780, 350, 150, 50); - delay(100); - - // Caw 3: longer trailing caw with more rasp - fyCaw(820, 280, 220, 60); - delay(80); - - // Quick staccato ending "kk-kk" - tone(BUZZER_PIN, 600, 25); delay(40); - tone(BUZZER_PIN, 550, 25); delay(40); - noTone(BUZZER_PIN); - - printf("[FLOCK-YOU] *caw caw caw*\n"); -} - -static void fyDetectBeep() { - printf("[FLOCK-YOU] Detection alert!\n"); - if (!fyBuzzerOn) return; - // Alarm crow: two sharp ascending chirps then a caw - fyCaw(400, 900, 100, 30); // rising alarm chirp - delay(60); - fyCaw(450, 950, 100, 30); // second chirp, higher - delay(60); - fyCaw(900, 350, 200, 50); // descending caw -} - -static void fyHeartbeat() { - if (!fyBuzzerOn) return; - // Soft double coo - like a distant crow - fyCaw(500, 400, 80, 20); - delay(120); - fyCaw(480, 380, 80, 20); -} - -// ============================================================================ -// DETECTION HELPERS -// ============================================================================ - -static bool checkFlockMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(flock_mac_prefixes)/sizeof(flock_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, flock_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkFlockMfrMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(flock_mfr_mac_prefixes)/sizeof(flock_mfr_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, flock_mfr_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkSoundThinkingMAC(const char* mac_str) { - for (size_t i = 0; i < sizeof(soundthinking_mac_prefixes)/sizeof(soundthinking_mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, soundthinking_mac_prefixes[i], 8) == 0) return true; - } - return false; -} - -static bool checkDeviceName(const char* name) { - if (!name || !name[0]) return false; - for (size_t i = 0; i < sizeof(device_name_patterns)/sizeof(device_name_patterns[0]); i++) { - if (strcasestr(name, device_name_patterns[i])) return true; - } - return false; -} - -static bool checkManufacturerID(uint16_t id) { - for (size_t i = 0; i < sizeof(ble_manufacturer_ids)/sizeof(ble_manufacturer_ids[0]); i++) { - if (ble_manufacturer_ids[i] == id) return true; - } - return false; -} - -// ============================================================================ -// RAVEN UUID DETECTION -// ============================================================================ - -static bool checkRavenUUID(NimBLEAdvertisedDevice* device, char* out_uuid = nullptr) { - if (!device || !device->haveServiceUUID()) return false; - int count = device->getServiceUUIDCount(); - if (count == 0) return false; - for (int i = 0; i < count; i++) { - NimBLEUUID svc = device->getServiceUUID(i); - std::string str = svc.toString(); - for (size_t j = 0; j < sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]); j++) { - if (strcasecmp(str.c_str(), raven_service_uuids[j]) == 0) { - if (out_uuid) strncpy(out_uuid, str.c_str(), 40); - return true; - } - } - } - return false; -} - -static const char* estimateRavenFW(NimBLEAdvertisedDevice* device) { - if (!device || !device->haveServiceUUID()) return "?"; - bool has_new_gps = false, has_old_loc = false, has_power = false; - int count = device->getServiceUUIDCount(); - for (int i = 0; i < count; i++) { - std::string u = device->getServiceUUID(i).toString(); - if (strcasecmp(u.c_str(), RAVEN_GPS_SERVICE) == 0) has_new_gps = true; - if (strcasecmp(u.c_str(), RAVEN_OLD_LOCATION_SERVICE) == 0) has_old_loc = true; - if (strcasecmp(u.c_str(), RAVEN_POWER_SERVICE) == 0) has_power = true; - } - if (has_old_loc && !has_new_gps) return "1.1.x"; - if (has_new_gps && !has_power) return "1.2.x"; - if (has_new_gps && has_power) return "1.3.x"; - return "?"; -} - -// ============================================================================ -// GPS HELPERS (mutex-protected snapshot pattern) -// ============================================================================ - -// Fast advisory check — safe lock-free (for UI/stats only, don't trust for writes) -static bool fyGPSIsFresh() { - return fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); -} - -// Atomic snapshot: returns true and fills out-params if GPS is fresh & valid. -// Safe to call from BLE callback context — never races with producer. -static bool fyGPSSnapshot(double& lat, double& lon, float& acc) { - if (!fyGPSMutex) return false; - if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return false; - bool fresh = fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); - if (fresh) { lat = fyGPSLat; lon = fyGPSLon; acc = fyGPSAcc; } - xSemaphoreGive(fyGPSMutex); - return fresh; -} - -// Atomic GPS publish (from phone via /api/gps or from companion app) -static void fyGPSUpdate(double lat, double lon, float acc) { - if (!fyGPSMutex) return; - if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return; - fyGPSLat = lat; - fyGPSLon = lon; - fyGPSAcc = acc; - fyGPSValid = true; - fyGPSLastUpdate = millis(); - xSemaphoreGive(fyGPSMutex); -} - -// Stamp a detection with current GPS if available (used at first-sight and re-sight) -static void fyAttachGPS(FYDetection& d) { - double lat, lon; - float acc; - if (fyGPSSnapshot(lat, lon, acc)) { - d.hasGPS = true; - d.gpsLat = lat; - d.gpsLon = lon; - d.gpsAcc = acc; - } -} - -// Periodic: back-fill GPS on detections recorded before a fix was available. -// Runs in main loop — MUST NOT be called from BLE callback (takes fyMutex). -static void fyBackfillGPS() { - double lat, lon; - float acc; - if (!fyGPSSnapshot(lat, lon, acc)) return; - if (!fyMutex || xSemaphoreTake(fyMutex, pdMS_TO_TICKS(50)) != pdTRUE) return; - int filled = 0; - for (int i = 0; i < fyDetCount; i++) { - if (!fyDet[i].hasGPS) { - fyDet[i].hasGPS = true; - fyDet[i].gpsLat = lat; - fyDet[i].gpsLon = lon; - fyDet[i].gpsAcc = acc; - filled++; - } - } - xSemaphoreGive(fyMutex); - if (filled) printf("[FLOCK-YOU] GPS backfilled %d detection(s)\n", filled); -} - -// ============================================================================ -// CRC32 (IEEE 802.3) — for session file integrity -// ============================================================================ - -static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) { - crc = ~crc; - while (len--) { - crc ^= *data++; - for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320UL & -(crc & 1)); - } - return ~crc; -} - -// ============================================================================ -// DETECTION MANAGEMENT -// ============================================================================ - -static int fyAddDetection(const char* mac, const char* name, int rssi, - const char* method, bool isRaven = false, - const char* ravenFW = "") { - if (!fyMutex || xSemaphoreTake(fyMutex, pdMS_TO_TICKS(100)) != pdTRUE) return -1; - - // Update existing by MAC - for (int i = 0; i < fyDetCount; i++) { - if (strcasecmp(fyDet[i].mac, mac) == 0) { - fyDet[i].count++; - fyDet[i].lastSeen = millis(); - fyDet[i].rssi = rssi; - if (name && name[0]) { - strncpy(fyDet[i].name, name, sizeof(fyDet[i].name) - 1); - } - // Update GPS on every re-sighting (captures movement) - fyAttachGPS(fyDet[i]); - xSemaphoreGive(fyMutex); - return i; - } - } - - // Add new - if (fyDetCount < MAX_DETECTIONS) { - FYDetection& d = fyDet[fyDetCount]; - memset(&d, 0, sizeof(d)); - strncpy(d.mac, mac, sizeof(d.mac) - 1); - // Sanitize name for JSON safety - if (name) { - for (int j = 0; j < (int)sizeof(d.name) - 1 && name[j]; j++) { - d.name[j] = (name[j] == '"' || name[j] == '\\') ? '_' : name[j]; - } - } - d.rssi = rssi; - strncpy(d.method, method, sizeof(d.method) - 1); - d.firstSeen = millis(); - d.lastSeen = millis(); - d.count = 1; - d.isRaven = isRaven; - strncpy(d.ravenFW, ravenFW ? ravenFW : "", sizeof(d.ravenFW) - 1); - // Attach GPS from phone - fyAttachGPS(d); - int idx = fyDetCount++; - xSemaphoreGive(fyMutex); - return idx; - } - - xSemaphoreGive(fyMutex); - return -1; -} - -// ============================================================================ -// BLE GATT SERVER (DeFlock companion connectivity) -// ============================================================================ - -class FYServerCallbacks : public NimBLEServerCallbacks { - void onConnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { - fyBLEClientConnected = true; - fyCompanionChangePending = true; - } - void onDisconnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { - fyBLEClientConnected = false; - fyNegotiatedMTU = 23; - NimBLEDevice::startAdvertising(); - fyCompanionChangePending = true; - } - void onMTUChange(uint16_t mtu, ble_gap_conn_desc* desc) override { - fyNegotiatedMTU = mtu; - printf("[FLOCK-YOU] MTU negotiated: %u\n", mtu); - } -}; - -static void fySendBLE(const char* data, size_t len) { - if (!fyBLEClientConnected || !fyTxChar) return; - uint16_t chunkSize = fyNegotiatedMTU - 3; - if (chunkSize < 1) chunkSize = 1; - if (len <= chunkSize) { - fyTxChar->setValue((const uint8_t*)data, len); - fyTxChar->notify(); - } else { - size_t offset = 0; - while (offset < len) { - size_t remaining = len - offset; - size_t send = remaining < chunkSize ? remaining : chunkSize; - fyTxChar->setValue((const uint8_t*)(data + offset), send); - fyTxChar->notify(); - offset += send; - } - } -} - -// ============================================================================ -// COMPANION MODE (WiFi AP vs BLE/serial) -// ============================================================================ - -static void fyOnCompanionChange() { - if (fyBLEClientConnected || fySerialHostConnected) { - // Companion mode — disable WiFi AP, boost BLE scanning - WiFi.softAPdisconnect(true); - WiFi.mode(WIFI_OFF); - fyBleScanDuration = 3; - printf("[FLOCK-YOU] Companion mode: WiFi AP OFF, scan duration %ds\n", - fyBleScanDuration); - } else { - // Standalone mode — re-enable WiFi AP and web dashboard - WiFi.mode(WIFI_AP); - delay(100); - WiFi.softAP(FY_AP_SSID, FY_AP_PASS); - fyBleScanDuration = 2; - printf("[FLOCK-YOU] Standalone mode: WiFi AP ON (%s), scan duration %ds\n", - FY_AP_SSID, fyBleScanDuration); - } -} - -// ============================================================================ -// BLE SCANNING -// ============================================================================ - -class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { - void onResult(NimBLEAdvertisedDevice* dev) override { - NimBLEAddress addr = dev->getAddress(); - std::string addrStr = addr.toString(); - - // Extract MAC prefix string for OUI checks - char macPrefix[9]; - snprintf(macPrefix, sizeof(macPrefix), "%.8s", addrStr.c_str()); - - int rssi = dev->getRSSI(); - std::string name = dev->haveName() ? dev->getName() : ""; - - bool detected = false; - bool highConfidence = true; - const char* method = ""; - bool isRaven = false; - const char* ravenFW = ""; - - // 1. Check Flock Safety direct OUIs (high confidence) - if (checkFlockMAC(macPrefix)) { - detected = true; - method = "mac_prefix"; - } - - // 2. Check SoundThinking/ShotSpotter OUIs (high confidence) - if (!detected && checkSoundThinkingMAC(macPrefix)) { - detected = true; - method = "mac_prefix_soundthinking"; - } - - // 3. Check Flock contract manufacturer OUIs (low confidence) - if (!detected && checkFlockMfrMAC(macPrefix)) { - detected = true; - method = "mac_prefix_mfr"; - highConfidence = false; - } - - // 4. Check BLE device name patterns - if (!detected && !name.empty() && checkDeviceName(name.c_str())) { - detected = true; - method = "device_name"; - } - - // 5. Check BLE manufacturer company IDs (from wgreenberg/flock-you) - if (!detected) { - for (int i = 0; i < (int)dev->getManufacturerDataCount(); i++) { - std::string data = dev->getManufacturerData(i); - if (data.size() >= 2) { - uint16_t code = ((uint16_t)(uint8_t)data[1] << 8) | - (uint16_t)(uint8_t)data[0]; - if (checkManufacturerID(code)) { - detected = true; - method = "ble_mfr_id"; - break; - } - } - } - } - - // 6. Check Raven gunshot detector service UUIDs - if (!detected) { - char detUUID[41] = {0}; - if (checkRavenUUID(dev, detUUID)) { - detected = true; - method = "raven_uuid"; - isRaven = true; - ravenFW = estimateRavenFW(dev); - } - } - - if (detected) { - int idx = fyAddDetection(addrStr.c_str(), name.c_str(), rssi, - method, isRaven, ravenFW); - - // Human-readable log - printf("[FLOCK-YOU] DETECTED: %s %s RSSI:%d [%s] count:%d\n", - addrStr.c_str(), name.c_str(), rssi, method, - idx >= 0 ? fyDet[idx].count : 0); - - // JSON output — build into buffer for serial + BLE - // Use atomic snapshot to avoid races with /api/gps writer - char gpsBuf[80] = ""; - { - double sLat, sLon; float sAcc; - if (fyGPSSnapshot(sLat, sLon, sAcc)) { - snprintf(gpsBuf, sizeof(gpsBuf), - ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", - sLat, sLon, sAcc); - } - } - char jsonBuf[512]; - int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), - "{\"event\":\"detection\",\"detection_method\":\"%s\"," - "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," - "\"device_name\":\"%s\",\"rssi\":%d," - "\"is_raven\":%s,\"raven_fw\":\"%s\"%s}", - method, addrStr.c_str(), name.c_str(), rssi, - isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); - printf("%s\n", jsonBuf); - // Append newline for BLE framing and send - if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { - jsonBuf[jsonLen] = '\n'; - fySendBLE(jsonBuf, jsonLen + 1); - } - - if (!fyTriggered && highConfidence) { - fyTriggered = true; - fyDetectBeep(); - } - if (highConfidence) { - fyDeviceInRange = true; - fyLastDetTime = millis(); - fyLastHB = millis(); - } - } - } -}; - -// ============================================================================ -// JSON HELPER -// ============================================================================ - -static void writeDetectionsJSON(AsyncResponseStream *resp) { - resp->print("["); - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - if (i > 0) resp->print(","); - resp->printf( - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"", - fyDet[i].mac, fyDet[i].name, fyDet[i].rssi, fyDet[i].method, - fyDet[i].firstSeen, fyDet[i].lastSeen, fyDet[i].count, - fyDet[i].isRaven ? "true" : "false", fyDet[i].ravenFW); - // Append GPS if present - if (fyDet[i].hasGPS) { - resp->printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", - fyDet[i].gpsLat, fyDet[i].gpsLon, fyDet[i].gpsAcc); - } - resp->print("}"); - } - xSemaphoreGive(fyMutex); - } - resp->print("]"); -} - -// ============================================================================ -// SESSION PERSISTENCE (SPIFFS) — bulletproof envelope format -// ============================================================================ -// -// Wire format on disk: -// Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n -// Line 2+: [{"mac":...},{"mac":...},...] (exactly B bytes, CRC32 == X) -// -// Atomic write procedure: -// 1. Compute size+CRC over the detections payload (pass 1, under fyMutex) -// 2. Write envelope header + payload to /session.tmp (pass 2, under same lock) -// 3. Remove /session.json -// 4. Rename /session.tmp → /session.json (with copy+delete fallback) -// -// Recovery: if /session.json is missing or CRC-invalid, fall back to /session.tmp. - -#define FY_SESSION_TMP "/session.tmp" - -// Serialize a single detection to `dst`. Returns bytes written (0 on overflow). -static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) { - int n; - if (d.hasGPS) { - n = snprintf(dst, cap, - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"," - "\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}}", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW, - d.gpsLat, d.gpsLon, d.gpsAcc); - } else { - n = snprintf(dst, cap, - "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," - "\"first\":%lu,\"last\":%lu,\"count\":%d," - "\"raven\":%s,\"fw\":\"%s\"}", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW); - } - return (n > 0 && (size_t)n < cap) ? (size_t)n : 0; -} - -// Pass 1: compute exact payload size + CRC32 without allocating. -// Caller MUST hold fyMutex. -static uint32_t fyComputePayloadCRC(size_t& outBytes) { - char line[512]; - 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; -} - -// Validate a session file envelope and its payload CRC. Returns true if intact. -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; } - - JsonDocument doc; - if (deserializeJson(doc, hdr) != DeserializationError::Ok) { f.close(); return false; } - if ((int)(doc["v"] | 0) != 1) { f.close(); return false; } - size_t expectedBytes = (size_t)(doc["bytes"] | 0); - uint32_t expectedCRC = 0; - const char* crcStr = doc["crc"] | ""; - if (sscanf(crcStr, "%x", &expectedCRC) != 1) { f.close(); return false; } - - size_t bodyOffset = hdr.length() + 1; - size_t fileSize = f.size(); - if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; } - size_t actualBytes = fileSize - bodyOffset; - if (actualBytes != 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); -} - -// Copy src→dst in chunks. Returns true on success. -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; -} - -// Atomic rename: try SPIFFS rename first, fall back to copy+delete if rename fails. -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 || !fyMutex) return; - if (xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) != pdTRUE) { - printf("[FLOCK-YOU] Save skipped: fyMutex busy\n"); - return; - } - - // Pass 1: compute CRC + byte count - size_t payloadBytes = 0; - uint32_t crc = fyComputePayloadCRC(payloadBytes); - int savedCount = fyDetCount; - - // Pass 2: write envelope + payload to tmp - File f = SPIFFS.open(FY_SESSION_TMP, "w"); - if (!f) { - xSemaphoreGive(fyMutex); - printf("[FLOCK-YOU] 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[512]; - 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(); - xSemaphoreGive(fyMutex); - - if (wrote != payloadBytes) { - printf("[FLOCK-YOU] Save WARNING: wrote %u expected %u — aborting promote\n", - (unsigned)wrote, (unsigned)payloadBytes); - return; - } - - if (!fyValidateSessionFile(FY_SESSION_TMP)) { - printf("[FLOCK-YOU] Save verify FAILED — aborting promote (old session preserved)\n"); - return; - } - - SPIFFS.remove(FY_SESSION_FILE); - if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) { - printf("[FLOCK-YOU] Promote FAILED — data in %s for recovery\n", FY_SESSION_TMP); - return; - } - - fyLastSaveCount = savedCount; - printf("[FLOCK-YOU] Session saved: %d det, %u bytes, crc=0x%08lX\n", - savedCount, (unsigned)payloadBytes, (unsigned long)crc); -} - -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)) { - printf("[FLOCK-YOU] Main session corrupt/missing — recovering from tmp\n"); - source = FY_SESSION_TMP; - } else { - // Legacy fallback: old format (raw array, no envelope) - if (SPIFFS.exists(FY_SESSION_FILE)) { - File f = SPIFFS.open(FY_SESSION_FILE, "r"); - if (f && f.size() > 2) { - int first = f.peek(); - f.close(); - if (first == '[') { - source = FY_SESSION_FILE; - printf("[FLOCK-YOU] Legacy-format session detected — promoting\n"); - } - } else if (f) { f.close(); } - } - } - - if (!source) { - if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE); - if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); - printf("[FLOCK-YOU] No valid prior session to promote\n"); - return; - } - - if (!fySpiffsCopy(source, FY_PREV_FILE)) { - printf("[FLOCK-YOU] 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(); - printf("[FLOCK-YOU] Prior session promoted from %s (%u bytes)\n", source, (unsigned)sz); -} - -// Read prev_session as a raw detection JSON array (strips envelope header if present). -static void fyStreamPrevSessionBody(AsyncResponseStream* resp) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { resp->print("[]"); return; } - File f = SPIFFS.open(FY_PREV_FILE, "r"); - if (!f) { resp->print("[]"); return; } - - int first = f.peek(); - if (first == '{') f.readStringUntil('\n'); - - uint8_t buf[256]; - int n; - size_t streamed = 0; - while ((n = f.read(buf, sizeof(buf))) > 0) { - resp->write(buf, (size_t)n); - streamed += (size_t)n; - } - f.close(); - if (streamed == 0) resp->print("[]"); -} - -// ============================================================================ -// KML EXPORT -// ============================================================================ - -static void writeDetectionsKML(AsyncResponseStream *resp) { - resp->print("\n" - "\n\n" - "Flock-You Detections\n" - "Surveillance device detections with GPS\n"); - - // Detection pin style - resp->print("\n" - "\n"); - - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - FYDetection& d = fyDet[i]; - if (!d.hasGPS) continue; // Skip detections without GPS - resp->print("\n"); - resp->printf("%s\n", d.mac); - resp->printf("#%s\n", d.isRaven ? "raven" : "det"); - resp->print("printf("Name: %s
", d.name); - resp->printf("Method: %s
" - "RSSI: %d dBm
" - "Count: %d
", - d.method, d.rssi, d.count); - if (d.isRaven) resp->printf("Raven FW: %s
", d.ravenFW); - resp->printf("Accuracy: %.1f m", d.gpsAcc); - resp->print("]]>
\n"); - resp->printf("%.8f,%.8f,0\n", - d.gpsLon, d.gpsLat); - resp->print("
\n"); - } - xSemaphoreGive(fyMutex); - } - resp->print("
\n
"); -} - -// ============================================================================ -// DASHBOARD HTML -// ============================================================================ - -static const char FY_HTML[] PROGMEM = R"rawliteral( - - -FLOCK-YOU - -

FLOCK-YOU

Surveillance Device Detector • Wardriving + GPS
-
-
0
DETECTED
-
0
RAVEN
-
ON
BLE
-
TAP
GPS
-
-
- - - - -
-
-
-
Scanning for surveillance devices...
BLE active on all channels
-
-
Loading prior session...
-
Loading patterns...
-
-

EXPORT DETECTIONS

-

Download current session to import into Flask dashboard

- - - -
-

PRIOR SESSION

- - -
- -
-
- -)rawliteral"; - -// ============================================================================ -// WEB SERVER SETUP -// ============================================================================ - -static void fySetupServer() { - // Dashboard - fyServer.on("/", HTTP_GET, [](AsyncWebServerRequest *r) { - r->send(200, "text/html", FY_HTML); - }); - - // API: Detection list - fyServer.on("/api/detections", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - writeDetectionsJSON(resp); - r->send(resp); - }); - - // API: Stats (includes GPS status) - fyServer.on("/api/stats", HTTP_GET, [](AsyncWebServerRequest *r) { - int raven = 0, withGPS = 0; - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(100)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - if (fyDet[i].isRaven) raven++; - if (fyDet[i].hasGPS) withGPS++; - } - xSemaphoreGive(fyMutex); - } - char buf[256]; - snprintf(buf, sizeof(buf), - "{\"total\":%d,\"raven\":%d,\"ble\":\"active\"," - "\"gps_valid\":%s,\"gps_age\":%lu,\"gps_tagged\":%d}", - fyDetCount, raven, - fyGPSIsFresh() ? "true" : "false", - fyGPSValid ? (millis() - fyGPSLastUpdate) : 0UL, - withGPS); - r->send(200, "application/json", buf); - }); - - // API: Receive GPS from phone browser (atomic publish under fyGPSMutex) - fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) { - if (r->hasParam("lat") && r->hasParam("lon")) { - double lat = r->getParam("lat")->value().toDouble(); - double lon = r->getParam("lon")->value().toDouble(); - float acc = r->hasParam("acc") ? r->getParam("acc")->value().toFloat() : 0; - fyGPSUpdate(lat, lon, acc); - r->send(200, "application/json", "{\"status\":\"ok\"}"); - } else { - r->send(400, "application/json", "{\"error\":\"lat,lon required\"}"); - } - }); - - // API: Pattern database - fyServer.on("/api/patterns", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->print("{\"macs\":["); - for (size_t i = 0; i < sizeof(flock_mac_prefixes)/sizeof(flock_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", flock_mac_prefixes[i]); - } - resp->print("],\"macs_mfr\":["); - for (size_t i = 0; i < sizeof(flock_mfr_mac_prefixes)/sizeof(flock_mfr_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", flock_mfr_mac_prefixes[i]); - } - resp->print("],\"macs_soundthinking\":["); - for (size_t i = 0; i < sizeof(soundthinking_mac_prefixes)/sizeof(soundthinking_mac_prefixes[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", soundthinking_mac_prefixes[i]); - } - resp->print("],\"names\":["); - for (size_t i = 0; i < sizeof(device_name_patterns)/sizeof(device_name_patterns[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", device_name_patterns[i]); - } - resp->print("],\"mfr\":["); - for (size_t i = 0; i < sizeof(ble_manufacturer_ids)/sizeof(ble_manufacturer_ids[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("%u", ble_manufacturer_ids[i]); - } - resp->print("],\"raven\":["); - for (size_t i = 0; i < sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]); i++) { - if (i > 0) resp->print(","); - resp->printf("\"%s\"", raven_service_uuids[i]); - } - resp->print("]}"); - r->send(resp); - }); - - // API: Export JSON (downloadable file) - fyServer.on("/api/export/json", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.json\""); - writeDetectionsJSON(resp); - r->send(resp); - }); - - // API: Export CSV (downloadable file, includes GPS) - fyServer.on("/api/export/csv", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("text/csv"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.csv\""); - resp->println("mac,name,rssi,method,first_seen_ms,last_seen_ms,count,is_raven,raven_fw,latitude,longitude,gps_accuracy"); - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) { - for (int i = 0; i < fyDetCount; i++) { - FYDetection& d = fyDet[i]; - if (d.hasGPS) { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",%.8f,%.8f,%.1f\n", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW, - d.gpsLat, d.gpsLon, d.gpsAcc); - } else { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",,,\n", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW); - } - } - xSemaphoreGive(fyMutex); - } - r->send(resp); - }); - - // API: Export KML (GPS-tagged detections for Google Earth) - fyServer.on("/api/export/kml", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/vnd.google-earth.kml+xml"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.kml\""); - writeDetectionsKML(resp); - r->send(resp); - }); - - // API: Prior session history (JSON) — strips envelope header if present - fyServer.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) { - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - fyStreamPrevSessionBody(resp); - r->send(resp); - }); - - // API: Download prior session as JSON file (body-only, envelope stripped) - fyServer.on("/api/history/json", HTTP_GET, [](AsyncWebServerRequest *r) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { - r->send(404, "application/json", "{\"error\":\"no prior session\"}"); - return; - } - AsyncResponseStream *resp = r->beginResponseStream("application/json"); - resp->addHeader("Content-Disposition", - "attachment; filename=\"flockyou_prev_session.json\""); - fyStreamPrevSessionBody(resp); - r->send(resp); - }); - - // API: Download prior session as KML (reads JSON body from SPIFFS, converts) - fyServer.on("/api/history/kml", HTTP_GET, [](AsyncWebServerRequest *r) { - if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { - r->send(404, "application/json", "{\"error\":\"no prior session\"}"); - return; - } - File f = SPIFFS.open(FY_PREV_FILE, "r"); - if (!f) { r->send(500, "text/plain", "read error"); return; } - // Strip envelope header if present - if (f.peek() == '{') f.readStringUntil('\n'); - String content = f.readString(); - f.close(); - if (content.length() == 0) { - r->send(404, "application/json", "{\"error\":\"prior session empty\"}"); - return; - } - AsyncResponseStream *resp = r->beginResponseStream("application/vnd.google-earth.kml+xml"); - resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_prev_session.kml\""); - resp->print("\n" - "\n\n" - "Flock-You Prior Session\n" - "Surveillance device detections from prior session\n" - "\n" - "\n"); - // Parse JSON array and emit placemarks - JsonDocument doc; - DeserializationError err = deserializeJson(doc, content); - if (!err && doc.is()) { - int placed = 0; - for (JsonObject d : doc.as()) { - JsonObject gps = d["gps"]; - if (!gps || !gps.containsKey("lat")) continue; - bool isRaven = d["raven"] | false; - resp->printf("%s\n", d["mac"] | "?"); - resp->printf("#%s\n", isRaven ? "raven" : "det"); - resp->print("() && strlen(d["name"] | "") > 0) - resp->printf("Name: %s
", d["name"] | ""); - resp->printf("Method: %s
RSSI: %d
Count: %d", - d["method"] | "?", d["rssi"] | 0, d["count"] | 1); - if (isRaven && d["fw"].is()) - resp->printf("
Raven FW: %s", d["fw"] | ""); - resp->print("]]>
\n"); - resp->printf("%.8f,%.8f,0\n", - (double)(gps["lon"] | 0.0), (double)(gps["lat"] | 0.0)); - resp->print("
\n"); - placed++; - } - printf("[FLOCK-YOU] Prior session KML: %d placemarks\n", placed); - } else { - printf("[FLOCK-YOU] Prior session KML: JSON parse failed\n"); - } - resp->print("
\n
"); - r->send(resp); - }); - - // API: Clear all detections (saves current session first) - fyServer.on("/api/clear", HTTP_GET, [](AsyncWebServerRequest *r) { - fySaveSession(); // Persist before clearing - if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { - fyDetCount = 0; - memset(fyDet, 0, sizeof(fyDet)); - fyTriggered = false; - fyDeviceInRange = false; - xSemaphoreGive(fyMutex); - } - r->send(200, "application/json", "{\"status\":\"cleared\"}"); - printf("[FLOCK-YOU] All detections cleared (session saved)\n"); - }); - - fyServer.begin(); - printf("[FLOCK-YOU] Web server started on port 80\n"); -} - -// ============================================================================ -// MAIN FUNCTIONS -// ============================================================================ - -void setup() { - Serial.begin(115200); - delay(500); - - // Standalone mode: buzzer always on by default - fyBuzzerOn = true; - - pinMode(BUZZER_PIN, OUTPUT); - digitalWrite(BUZZER_PIN, LOW); - - fyMutex = xSemaphoreCreateMutex(); - fyGPSMutex = xSemaphoreCreateMutex(); - - // Init SPIFFS for session persistence - if (SPIFFS.begin(true)) { - fySpiffsReady = true; - printf("[FLOCK-YOU] SPIFFS ready\n"); - // Promote last session to prev_session before we start a new one - fyPromotePrevSession(); - } else { - printf("[FLOCK-YOU] SPIFFS init failed - no persistence\n"); - } - - printf("\n========================================\n"); - printf(" FLOCK-YOU Surveillance Detector\n"); - printf(" Buzzer: %s\n", fyBuzzerOn ? "ON" : "OFF"); - printf("========================================\n"); - - // Init BLE with device name and large MTU for GATT notifications - NimBLEDevice::init("flockyou"); - NimBLEDevice::setMTU(512); - - // BLE scanner setup - fyBLEScan = NimBLEDevice::getScan(); - fyBLEScan->setAdvertisedDeviceCallbacks(new FYBLECallbacks()); - fyBLEScan->setActiveScan(true); - fyBLEScan->setInterval(100); - fyBLEScan->setWindow(99); - - // Kick off the first scan right away - fyBLEScan->start(fyBleScanDuration, false); - fyLastBleScan = millis(); - printf("[FLOCK-YOU] BLE scanning ACTIVE\n"); - - // BLE GATT server — DeFlock app connectivity - fyBLEServer = NimBLEDevice::createServer(); - fyBLEServer->setCallbacks(new FYServerCallbacks()); - NimBLEService* pService = fyBLEServer->createService(FY_SERVICE_UUID); - fyTxChar = pService->createCharacteristic( - FY_TX_CHAR_UUID, - NIMBLE_PROPERTY::NOTIFY - ); - pService->start(); - - NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); - pAdv->addServiceUUID(FY_SERVICE_UUID); - pAdv->setName("flockyou"); - pAdv->setScanResponse(true); - pAdv->start(); - printf("[FLOCK-YOU] BLE GATT server advertising (service %s)\n", FY_SERVICE_UUID); - - // Crow calls play WHILE BLE is already scanning - fyBootBeep(); - - // Start WiFi AP (no need to connect to anything -- AP only) - WiFi.mode(WIFI_AP); - delay(100); - WiFi.softAP(FY_AP_SSID, FY_AP_PASS); - printf("[FLOCK-YOU] AP: %s / %s\n", FY_AP_SSID, FY_AP_PASS); - printf("[FLOCK-YOU] IP: %s\n", WiFi.softAPIP().toString().c_str()); - - // Start web dashboard - fySetupServer(); - - printf("[FLOCK-YOU] Detection methods: MAC prefix, device name, manufacturer ID, Raven UUID\n"); - printf("[FLOCK-YOU] Dashboard: http://192.168.4.1\n"); - printf("[FLOCK-YOU] Ready - BLE GATT + AP mode\n\n"); -} - -void loop() { - // Serial host detection (heartbeat from DeFlock desktop app) - if (Serial.available()) { - while (Serial.available()) Serial.read(); // drain buffer - fyLastSerialHeartbeat = millis(); - if (!fySerialHostConnected) { - fySerialHostConnected = true; - fyCompanionChangePending = true; - } - } else if (fySerialHostConnected && - millis() - fyLastSerialHeartbeat >= FY_SERIAL_TIMEOUT_MS) { - fySerialHostConnected = false; - fyCompanionChangePending = true; - } - - // Apply deferred companion mode switch (from BLE callbacks or serial detection) - if (fyCompanionChangePending) { - fyCompanionChangePending = false; - fyOnCompanionChange(); - } - - // BLE scanning cycle - if (millis() - fyLastBleScan >= fyBleScanInterval && !fyBLEScan->isScanning()) { - fyBLEScan->start(fyBleScanDuration, false); - fyLastBleScan = millis(); - } - - if (!fyBLEScan->isScanning() && millis() - fyLastBleScan > (unsigned long)fyBleScanDuration * 1000) { - fyBLEScan->clearResults(); - } - - // Heartbeat tracking - if (fyDeviceInRange) { - if (millis() - fyLastHB >= 10000) { - fyHeartbeat(); - fyLastHB = millis(); - } - if (millis() - fyLastDetTime >= 30000) { - printf("[FLOCK-YOU] Device out of range - stopping heartbeat\n"); - fyDeviceInRange = false; - fyTriggered = false; - } - } - - // Back-fill GPS on any detections captured before the first fix (every 2s) - static unsigned long lastBackfill = 0; - if (millis() - lastBackfill >= 2000) { - fyBackfillGPS(); - lastBackfill = millis(); - } - - // Bulletproof save cadence: - // - within 5s of first detection (quick first-save) - // - any time fyDetCount increases (new unique device), throttled to 3s minimum - // - every FY_SAVE_INTERVAL (15s) as a safety net - if (fySpiffsReady && fyDetCount > 0) { - unsigned long now = millis(); - bool countChanged = (fyDetCount != fyLastSaveCount); - bool minGap = (now - fyLastSave >= 3000); - bool firstSave = (fyLastSaveCount == 0 && now - fyLastSave >= 5000); - bool periodic = (now - fyLastSave >= FY_SAVE_INTERVAL); - - if (firstSave || (countChanged && minGap) || periodic) { - fySaveSession(); - fyLastSave = millis(); - } - } - - delay(100); -} From 60bed01781162169db1fc237592e3adcd603e081 Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 08:45:49 -0400 Subject: [PATCH 5/8] audio: two-chirp on new MAC, monotone heartbeat while target stays in range Replaces the single beep-per-detection with two distinct patterns: - New MAC (first sighting): two fast ascending beeps, 2000 -> 2800 Hz, 55 ms each with 25 ms gap - Same MAC still active (last seen within 20 s): two monotone 1500 Hz heartbeat beeps, 70 ms each, every 10 s - Silent once nothing has been seen for 20 s, until the next new MAC Global "last seen" timer refreshes on every inbound hit, including ones suppressed by the serial rate limit, so quieter repeats still count as "still around" for the heartbeat. LED still flashes on every emitted detection. --- main.cpp | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/main.cpp b/main.cpp index b6585fc..7453897 100644 --- a/main.cpp +++ b/main.cpp @@ -41,6 +41,19 @@ static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(full #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 20000 +#define HB_BEEP_INTERVAL_MS 10000 +#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 @@ -181,6 +194,12 @@ 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 // ============================================================ @@ -254,6 +273,25 @@ static void buzzerBeep(unsigned int ms) { 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 @@ -853,6 +891,14 @@ static void drainAlertQueue() { int idx = fyAddDetection(macStr, method, e.rssi, e.channel, (e.type == ALERT_SSID) ? e.ssid : nullptr); + // A MAC is "new" iff fyAddDetection just inserted it (count is exactly 1). + bool isNew = (idx >= 0 && fyDet[idx].count == 1); + + // 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; @@ -874,8 +920,16 @@ static void drainAlertQueue() { emitDetectionJSON(macStr, method, e.rssi, e.channel, (e.type == ALERT_SSID) ? e.ssid : ""); - // Beep + LED flash on every emitted detection. - buzzerBeep(60); + // 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 (isNew) { + 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 @@ -897,6 +951,17 @@ static void autosaveTick() { 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 // ============================================================ @@ -974,6 +1039,7 @@ void loop() { updateChannelMode(); drainAlertQueue(); // Serial.printf happens here, not in callback 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); From 16b93838fb1a41dd08c03bf0cc8832cd7b9d53bd Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 08:46:38 -0400 Subject: [PATCH 6/8] audio: tighten heartbeat active window to 3 s Only consider a target 'still around' if it was seen within the last 3 seconds (was 20 s). Heartbeat interval stays 10 s. Net effect: the monotone beep-pair only fires while the device is actively in RF range, stops almost immediately on departure. --- main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.cpp b/main.cpp index 7453897..94dd9b4 100644 --- a/main.cpp +++ b/main.cpp @@ -44,7 +44,7 @@ static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(full // 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 20000 +#define HB_DEVICE_ACTIVE_MS 3000 #define HB_BEEP_INTERVAL_MS 10000 #define NEW_CHIRP_LO_HZ 2000 #define NEW_CHIRP_HI_HZ 2800 From f537c7d1941c3677a094b43341f3dba25ebf48fc Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Mon, 20 Apr 2026 08:50:17 -0400 Subject: [PATCH 7/8] audio: chirp again on rediscovery after 30 s of silence Known MACs that haven't been heard from in >30 s now refire the ascending new-discovery chirp when they reappear. Shorter gaps (like Flock burst-sleep cycles) still just resume the heartbeat beep-pair without a chirp. fyAddDetection now returns an 'outChirpWorthy' bool: true for brand-new MACs and for rediscoveries past the threshold. Replaces the old count==1 check, which only ever fired once per MAC per session. --- main.cpp | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/main.cpp b/main.cpp index 94dd9b4..0267886 100644 --- a/main.cpp +++ b/main.cpp @@ -46,6 +46,11 @@ static const size_t fullHopChannelCount = sizeof(fullHopChannels) / sizeof(full // 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 @@ -450,34 +455,46 @@ static const char* alertTypeToMethod(AlertType t) { } // 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) { + 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 = millis(); + 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) return -1; + 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 = millis(); - d.lastSeen = d.firstSeen; + 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; } @@ -888,11 +905,12 @@ static void drainAlertQueue() { 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); - - // A MAC is "new" iff fyAddDetection just inserted it (count is exactly 1). - bool isNew = (idx >= 0 && fyDet[idx].count == 1); + (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 @@ -924,7 +942,7 @@ static void drainAlertQueue() { // - 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 (isNew) { + 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. From 467901d2f7459dcf498bc432018461b8558e7346 Mon Sep 17 00:00:00 2001 From: Colonel Panic Date: Fri, 24 Apr 2026 06:40:03 -0400 Subject: [PATCH 8/8] wildcard-probe signature + 31st OUI (DeFlockJoplin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Michael / DeFlockJoplin's high-precision detection method on top of the NitekryDPaul baseline: a Flock camera is flagged when it transmits a Probe Request (type=0 subtype=4) with a wildcard SSID IE (tag 0 len 0) AND its addr2 matches the OUI list. Drive-test in Joplin: 11/12 cameras caught with only 2 false positives. - New AlertType ALERT_WILDCARD_PROBE, emitted as detection_method 'wifi_wildcard_probe' (high-precision class) - Wildcard-probe hits suppress the addr2 broad alert for the same frame to prevent double counting; non-probe OUI matches still emit as 'wifi_oui_addr2' - IE parser returns tri-state (1=wildcard / 0=directed / -1=no SSID IE), with FCS-trailer retry only on the -1 no-IE case - addr1 receiver-side sleeper-catch and the optional addr3 + SSID paths are unchanged — wildcard is purely additive - 31st OUI 82:6b:f2 added to target_ouis[] and to the dataset doc; it's the OUI of the 12th camera in Michael's drive-test that the original 30 didn't catch - README explains the wildcard-probe method, credits Michael with a link to github.com/DeflockJoplin/flock-you, and bumps Acknowledgments Source: https://github.com/DeflockJoplin/flock-you --- README.md | 37 ++++++++++++-- datasets/NitekryDPaul_wifi_ouis.md | 23 ++++++++- main.cpp | 80 ++++++++++++++++++++++++++---- 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0d8f971..288b459 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ -# Flock-You: Promiscuous WiFi Edition (`promiscious` branch) +# Flock-You: Promiscuous WiFi Edition (`promiscious-dev` branch) Flock You **Passive 2.4 GHz promiscuous-mode detector for Flock Safety surveillance infrastructure. Runs standalone or feeds the Flask dashboard over USB for live GPS-tagged wardriving.** +> **Dev note:** This is the `promiscious-dev` branch — adds the +> DeFlockJoplin wildcard-probe tightening and a 31st OUI on top of the +> `promiscious` baseline. See "Further research" below. + --- ## Credit All WiFi promiscuous detection research — the **30-OUI target list**, the **promiscuous-mode strategy**, and the **addr1-receiver detection technique** — is the work of **ØяĐöØцяöЪöяцฐ / @NitekryDPaul**. The firmware here is a mod of his original firmware with added SPIFFS persistence and Flask-dashboard integration. Full research writeup: [`datasets/NitekryDPaul_wifi_ouis.md`](datasets/NitekryDPaul_wifi_ouis.md). +Additional research credit to **Michael / DeFlockJoplin** for the **wildcard-probe-request signature** and the 31st OUI (`82:6b:f2`). Field-tested to 11/12 cameras caught with only 2 false positives in Joplin. Source: [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). + --- ## What this branch does @@ -41,6 +47,26 @@ Both are applied before the OUI match. This whole approach, including the 30-OUI --- +## Further research — the wildcard-probe signature (DeFlockJoplin) + +Michael / DeFlockJoplin used the OUI + addr1/addr2/addr3 work above as a starting point and characterised what Flock cameras actually do on the air. His finding: + +> The cameras are hopping channels and sending out a wildcard WiFi probe request on every channel. This specific type of request combined with OUI matching has created what seems to be a fairly unique signature. + +His drive-test in Joplin caught **11 of 12 cameras** with only **2 false positives**. The 12th camera was doing the same wildcard-probe behaviour but with an OUI (`82:6b:f2`) that wasn't in @NitekryDPaul's original 30 — it's now the 31st entry in our list, credited to him. + +The tightened signature that's active on this branch: + +1. Frame is 802.11 Management, type=0 subtype=4 (**Probe Request**) +2. SSID Information Element (tag 0) is present with **length 0** (wildcard) +3. `addr2` (transmitter) matches the known-OUI list + +When all three hit, we emit `detection_method: wifi_wildcard_probe` — the high-precision class. Non-probe frames from the same OUIs still emit `wifi_oui_addr2`, and the `addr1` receiver-side sleeper-catch still runs independently. + +His proof-of-concept firmware (different enough we're not just pulling it in wholesale, but the core idea carried over cleanly): [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). The wildcard-probe analysis is his; we ported the detection into this firmware and kept our SPIFFS persistence, Flask JSON emission, and audio/LED feedback on top. + +--- + ## Detection pipeline ``` @@ -78,7 +104,7 @@ The split between callback and loop is deliberate: the WiFi task has hard real-t ## OUI target list (@NitekryDPaul research) -All lowercase, colon-separated. 30 Flock Safety infrastructure prefixes: +All lowercase, colon-separated. 31 Flock Safety infrastructure prefixes: ``` 70:c9:4e 3c:91:80 d8:f3:bc 80:30:49 b8:35:32 @@ -87,6 +113,7 @@ All lowercase, colon-separated. 30 Flock Safety infrastructure prefixes: 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 +82:6b:f2 ← contributed by Michael / DeFlockJoplin ``` Pre-compiled into a byte table in `setup()` so the matcher stays entirely in IRAM with no flash-resident lookups during callback execution. @@ -133,7 +160,8 @@ The firmware emits one JSON line per detection in the same schema the BLE detect `detection_method` values: -- `wifi_oui_addr2` — transmitter-side OUI match +- `wifi_wildcard_probe` — **Probe Request + wildcard SSID from a known OUI** (the DeFlockJoplin high-precision signature). When this fires, the `addr2` broad alert is suppressed for the same frame to avoid double-counting. +- `wifi_oui_addr2` — transmitter-side OUI match on any non-probe frame - `wifi_oui_addr1` — **receiver-side OUI match** (the @NitekryDPaul technique) - `wifi_oui_addr3` — BSSID OUI match (mgmt frames only; disabled by default) - `wifi_ssid` — SSID keyword match (disabled by default) @@ -225,7 +253,8 @@ The BLE-only sibling of this firmware lives on the [`main` branch](https://githu ## Acknowledgments -- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that are the entirety of this firmware. The code here is a mod of his original work. +- **ØяĐöØцяöЪöяцฐ (@NitekryDPaul)** — **WiFi promiscuous detection research**: the 30-OUI Flock Safety target list and the addr1-receiver detection technique that are the baseline of this firmware. The code here is a mod of his original work. +- **Michael / DeFlockJoplin** ([DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you), [deflockjoplin.today](https://deflockjoplin.today)) — **wildcard-probe-request signature** + the 31st OUI (`82:6b:f2`). Drive-tested in Joplin to 11/12 cameras caught with only 2 false positives. - **Will Greenberg** ([@wgreenberg](https://github.com/wgreenberg)) — BLE manufacturer company ID detection (`0x09C8` XUNTONG) sourced from his [flock-you](https://github.com/wgreenberg/flock-you) fork (used by the BLE companion on `main`) - **[DeFlock](https://deflock.me)** ([FoggedLens/deflock](https://github.com/FoggedLens/deflock)) — crowdsourced ALPR location data and detection methodologies. Datasets included in `datasets/` - **[GainSec](https://github.com/GainSec)** — Raven BLE service UUID dataset (`raven_configurations.json`) used by the BLE companion diff --git a/datasets/NitekryDPaul_wifi_ouis.md b/datasets/NitekryDPaul_wifi_ouis.md index c5be892..fc4261d 100644 --- a/datasets/NitekryDPaul_wifi_ouis.md +++ b/datasets/NitekryDPaul_wifi_ouis.md @@ -10,7 +10,12 @@ Flock stations spend most of their duty cycle asleep, waking briefly to upload a This addr1 technique is @NitekryDPaul's discovery and is the basis of the `promiscuis-flock-you` firmware. -## OUI list (30 prefixes, lowercase, colon-separated) +## OUI list (31 prefixes, lowercase, colon-separated) + +@NitekryDPaul contributed the first 30. The 31st (`82:6b:f2`) was contributed +by **Michael / DeFlockJoplin** during follow-up drive-testing in Joplin — it's +the OUI of the 12th camera in his field test, which the original list didn't +catch. See [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). ``` 70:c9:4e @@ -43,6 +48,7 @@ ec:1b:bd 64:6e:69 48:27:ea a4:cf:12 +82:6b:f2 ``` ## CSV form @@ -79,6 +85,7 @@ a4:cf:12 | 64:6e:69 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | | 48:27:ea | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | | a4:cf:12 | Flock Safety infrastructure | WiFi 2.4 GHz | @NitekryDPaul | +| 82:6b:f2 | Flock Safety infrastructure | WiFi 2.4 GHz (wildcard probe) | Michael / DeFlockJoplin | ## Detection strategy @@ -90,6 +97,20 @@ For each observed 802.11 management or data frame: 4. Match `addr1` (receiver) against the OUI list — **the addr1 insight** 5. Optional: match `addr3` (BSSID) on mgmt frames when addr2 is randomised +### Wildcard-probe tightening (DeFlockJoplin) + +Michael / DeFlockJoplin observed that Flock cameras channel-hop and spam +wildcard 802.11 Probe Requests on every channel. Combining that with the +OUI match yields a very tight signature: + +1. Frame is Management, type=0 subtype=4 (Probe Request) +2. SSID Information Element (tag 0) is present with length 0 +3. `addr2` (transmitter) matches the OUI list + +Field-tested in Joplin: **11 of 12 cameras caught with only 2 false +positives**. The 12th camera used OUI `82:6b:f2`, which is now in the +list above. Source: [DeflockJoplin/flock-you](https://github.com/DeflockJoplin/flock-you). + ## Firmware The `promiscuis-flock-you` firmware implementing this research is a mod of @NitekryDPaul's promiscuous-mode firmware. It emits Flask-compatible JSON over USB for ingestion by the `flock-you` dashboard and persists detections to on-device SPIFFS. diff --git a/main.cpp b/main.cpp index 0267886..4621777 100644 --- a/main.cpp +++ b/main.cpp @@ -87,7 +87,11 @@ static const char* target_ouis[] = { "94:08:53", "e4:aa:ea", "f4:6a:dd", "f8:a2:d6", "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" + "90:35:ea", "5c:93:a2", "64:6e:69", "48:27:ea", "a4:cf:12", + // 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]); @@ -103,10 +107,14 @@ static uint8_t oui_bytes[OUI_COUNT][3]; #define ALERT_QUEUE_SIZE 32 typedef enum : uint8_t { - ALERT_OUI_ADDR2 = 0, - ALERT_OUI_ADDR1 = 1, - ALERT_OUI_ADDR3 = 2, - ALERT_SSID = 3, + 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 { @@ -446,11 +454,12 @@ static void printHeartbeat() { 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"; - default: return "unknown"; + 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"; } } @@ -797,6 +806,24 @@ static bool IRAM_ATTR extractSsidFromMgmtBody(const uint8_t* body, int len, 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; @@ -820,8 +847,39 @@ static void IRAM_ATTR wifiSniffer(void* buf, wifi_promiscuous_pkt_type_t type) { 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)) { - enqueueAlert(ALERT_OUI_ADDR2, hdr->addr2, rssi, ch, nullptr, "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