// ============================================================================ // DANTIR: Surveillance Counter-Watcher // ============================================================================ // "Go flock yourself." // Sindarin: dan (against/back) + tir (to watch) = counter-watcher // // Forked from OUI Spy Unified Blue — Flock-You mode // Original: https://github.com/colonelpanichacks/oui-spy-unified-blue // BLE detection research: https://github.com/wgreenberg/flock-you // // Detection methods: // BLE: // 1. MAC prefix matching (Flock Safety, Ring, Amazon OUIs) // 2. Device name pattern matching (case-insensitive substring) // 3. Manufacturer company ID matching (0x09C8 XUNTONG, 0x0171 Amazon) // 4. Raven gunshot detector service UUID matching // 5. Raven firmware version estimation from service UUID patterns // WiFi (promiscuous mode): // 6. Probe request source MAC OUI matching (Ring, Blink, Amazon) // 7. Beacon frame source MAC OUI matching // // WiFi AP "dantir" / "dantir123" serves web dashboard at 192.168.4.1 // All detections stored in memory + SPIFFS, exportable as JSON, CSV, KML // ============================================================================ #include #include #include #include #include #include #include #include #include #include #include #include #include "esp_wifi.h" #include #include #include // ============================================================================ // CONFIGURATION // ============================================================================ #define BUZZER_PIN 3 // Hardware GPS (Seeed L76K GNSS module) #define GPS_RX_PIN 44 // D7 — ESP32 RX <- GPS TX #define GPS_TX_PIN 43 // D6 — ESP32 TX -> GPS RX #define GPS_BAUD 9600 #define GPS_HDOP_SCALE 5.0f // HDOP * scale ≈ accuracy in meters // 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 // NeoPixel #define FY_NEOPIXEL_PIN 4 #define FY_NEOPIXEL_BRIGHTNESS 50 #define FY_NEOPIXEL_DETECTION_BRIGHTNESS 200 // Battery monitoring // XIAO ESP32-S3 has no dedicated battery ADC pin. To enable battery monitoring: // 1. Wire a voltage divider (2x 200K resistors) from BAT+ to an ADC pin // 2. Set BATTERY_ADC_PIN to that GPIO number (e.g. 1 for A0/D0) // 3. The code reads voltage, applies divider ratio, maps to 0-100% // Set to -1 to disable (no hardware voltage divider connected) #define BATTERY_ADC_PIN -1 // -1 = disabled, set to GPIO with voltage divider #define BATTERY_SAMPLES 16 // ADC samples to average (noise reduction) #define BATTERY_FULL_V 4.2f // LiPo full charge voltage #define BATTERY_EMPTY_V 3.0f // LiPo cutoff voltage #define BATTERY_DIVIDER 2.0f // Voltage divider ratio (2x 200K = /2) // BLE scanning #define BLE_SCAN_DURATION 2 // seconds per scan #define BLE_SCAN_INTERVAL 3000 // ms between scans // Detection storage #define MAX_DETECTIONS 200 // WiFi AP credentials #define FY_AP_SSID "dantir" #define FY_AP_PASS "dantir123" // ============================================================================ // DETECTION PATTERNS — BLE // ============================================================================ // Known Flock Safety MAC address prefixes (OUIs) — matched during BLE scan static const char* ble_mac_prefixes[] = { // Flock Safety — FS Ext Battery devices "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", // Flock Safety — WiFi-enabled devices "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", "74:4c:a1", "9c:2f:9d", "94:08:53", "e4:aa:ea" // REMOVED 2026-06-23 (false-positive sources — see signatures/ audit): // "04:0d:84" — Silicon Labs OUI (Wyze Lock / BlueDriver / Philips Hue), NOT Flock // "08:3a:88" — Raspberry Pi Foundation OUI (millions of hobby RPis), too ambiguous // Both produced false "flock" tags. Real Flock-on-RPi now relies on name // (Penguin/Pigvision) + mfr ID 0x09C8, not these shared OUIs. }; // ============================================================================ // DETECTION CATEGORIES // ============================================================================ // Each detection is tagged with a category for differentiated alerts. // Categories map to Morse code letters for buzzer/haptic patterns: // F (··-·) = Flock/ALPR G (--·) = Glasses/recording // T (-) = Tracker L (·-··) = Law enforcement // R (·-·) = Ring/consumer S (···) = Surveillance cameras // V (···-) = Raven W (·--) = WiFi detection // BLE device name patterns (matched case-insensitive substring) struct NameEntry { const char* pattern; const char* category; }; static const NameEntry device_name_entries[] = { // Flock Safety {"FS Ext Battery", "flock"}, {"Penguin", "flock"}, {"Flock", "flock"}, {"Pigvision", "flock"}, // Ring / consumer cameras {"Ring", "ring"}, // Smart glasses / recording devices {"rayban", "glasses"}, {"ray-ban", "glasses"}, {"ray ban", "glasses"}, }; // BLE Manufacturer Company IDs (from Bluetooth SIG assigned numbers) struct MfrEntry { uint16_t id; const char* category; }; static const MfrEntry ble_manufacturer_entries[] = { // Flock Safety / ALPR {0x09C8, "flock"}, // XUNTONG — Flock Safety cameras // Consumer surveillance (Amazon ecosystem) {0x0171, "ring"}, // Amazon — Ring doorbells, Echo, Blink // Smart glasses / recording devices {0x01AB, "glasses"}, // Meta Platforms — Ray-Ban Meta {0x058E, "glasses"}, // Meta Platforms Technologies — Ray-Ban Meta {0x0D53, "glasses"}, // Luxottica/EssilorLuxottica — Oakley smart glasses {0x03C2, "glasses"}, // Snapchat — Spectacles // Law enforcement equipment {0x034D, "lawenf"}, // TASER International / Axon — body cameras {0x04EC, "lawenf"}, // Motorola Solutions — police radios/cameras // Tracking devices {0x067C, "tracker"}, // Tile — BLE trackers // Surveillance cameras {0x0E25, "camera"}, // Hangzhou Hikvision — surveillance cameras {0x0C19, "camera"}, // Arlo Technologies — security cameras {0x0870, "camera"}, // Wyze Labs — security cameras }; // ============================================================================ // DETECTION PATTERNS — WiFi (promiscuous mode) // ============================================================================ // These OUIs are checked against source MACs in WiFi management frames // (probe requests, beacon frames) captured via promiscuous callback. // WiFi-side surveillance OUI prefixes (matched in promiscuous mode). Each carries its // own category so matches aren't all assumed Ring (mirrors the shared signatures/ library). struct WifiPrefixEntry { const char* prefix; const char* category; }; static const WifiPrefixEntry wifi_mac_prefixes[] = { // Ring LLC {"00:b4:63","ring"}, {"18:7f:88","ring"}, {"24:2b:d6","ring"}, {"34:3e:a4","ring"}, {"54:e0:19","ring"}, {"5c:47:5e","ring"}, {"64:9a:63","ring"}, {"90:48:6c","ring"}, {"9c:76:13","ring"}, {"ac:9f:c3","ring"}, {"c4:db:ad","ring"}, {"cc:3b:fb","ring"}, // Blink by Amazon (Ring's sister brand) {"70:ad:43","ring"}, // Axon ALPR — catches Lightpost's 2.4GHz radio / Outpost in setup mode. // (Deployed Outpost is LTE-only/RF-silent; Lightpost's 5GHz is invisible to ESP32.) {"00:25:df","axon"}, }; // ============================================================================ // WIFI PROMISCUOUS MODE — frame subtypes // ============================================================================ #define WIFI_MGMT_PROBE_REQ 0x04 #define WIFI_MGMT_BEACON 0x08 // ============================================================================ // 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[24]; char category[12]; // flock, glasses, tracker, lawenf, ring, camera, raven, wifi unsigned long firstSeen; unsigned long lastSeen; int count; bool isRaven; char ravenFW[16]; // GPS from phone (wardriving) — first detection position double gpsLat; double gpsLon; float gpsAcc; bool hasGPS; // Peak-RSSI GPS — position at strongest signal (closest approach to device) int bestRSSI; // Strongest (least negative) RSSI seen double bestGPSLat; double bestGPSLon; float bestGPSAcc; bool hasBestGPS; }; static FYDetection fyDet[MAX_DETECTIONS]; static int fyDetCount = 0; static SemaphoreHandle_t fyMutex = NULL; // ============================================================================ // GLOBALS // ============================================================================ static bool fyBuzzerOn = true; static Adafruit_NeoPixel fyPixel(1, FY_NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800); static bool fyPixelAlertMode = false; static unsigned long fyPixelAlertStart = 0; static unsigned long fyLastBleScan = 0; static volatile bool fyWifiAlertPending = false; // Deferred from promiscuous CB static int fyWifiDetCount = 0; static bool fyDeviceInRange = false; static unsigned long fyLastDetTime = 0; static unsigned long fyLastHB = 0; // Per-category alert tracking — each category beeps once, then heartbeat takes over #define FY_MAX_ALERTED_CATS 8 static char fyAlertedCats[FY_MAX_ALERTED_CATS][12] = {}; static int fyAlertedCatCount = 0; static bool fyCategoryAlerted(const char* cat) { if (!cat) return false; for (int i = 0; i < fyAlertedCatCount; i++) { if (strcmp(fyAlertedCats[i], cat) == 0) return true; } return false; } static void fyMarkCategoryAlerted(const char* cat) { if (!cat || fyCategoryAlerted(cat)) return; if (fyAlertedCatCount < FY_MAX_ALERTED_CATS) { strlcpy(fyAlertedCats[fyAlertedCatCount], cat, sizeof(fyAlertedCats[0])); fyAlertedCatCount++; } } static void fyClearAlertedCategories() { fyAlertedCatCount = 0; memset(fyAlertedCats, 0, sizeof(fyAlertedCats)); } static NimBLEScan* fyBLEScan = NULL; static AsyncWebServer fyServer(80); // 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 // Hardware GPS state (Seeed L76K GNSS module on UART1) static TinyGPSPlus fyGPS; static HardwareSerial fyGPSSerial(1); static bool fyHWGPSDetected = false; // Any NMEA received = module present static bool fyHWGPSFix = false; // Valid position fix static int fyHWGPSSats = 0; // Satellite count static unsigned long fyHWGPSLastChar = 0; static bool fyGPSIsHardware = false; // Current GPS source is hardware #define GPS_HW_TIMEOUT_MS 5000 // 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; // Battery monitoring static int fyBatteryPercent = -1; // -1 = no ADC available static float fyBatteryVoltage = 0; static unsigned long fyLastBattRead = 0; #define BATTERY_READ_INTERVAL 10000 // Read every 10 seconds static void fyReadBattery() { #if BATTERY_ADC_PIN >= 0 if (millis() - fyLastBattRead < BATTERY_READ_INTERVAL && fyLastBattRead > 0) return; fyLastBattRead = millis(); uint32_t raw = 0; for (int i = 0; i < BATTERY_SAMPLES; i++) { raw += analogReadMilliVolts(BATTERY_ADC_PIN); } float mv = (float)raw / BATTERY_SAMPLES; fyBatteryVoltage = mv * BATTERY_DIVIDER / 1000.0f; int pct = (int)((fyBatteryVoltage - BATTERY_EMPTY_V) / (BATTERY_FULL_V - BATTERY_EMPTY_V) * 100.0f); if (pct > 100) pct = 100; if (pct < 0) pct = 0; fyBatteryPercent = pct; #else fyBatteryPercent = -1; #endif } // ============================================================================ // 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("[DANTIR] 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("[DANTIR] *caw caw caw*\n"); } // ============================================================================ // MORSE CODE ALERT SYSTEM // ============================================================================ // Each detection category plays its Morse code letter on the buzzer. // This serves dual purpose: identify device type + learn Morse code. // // Morse patterns (. = dit, - = dah): // F ··-· = Flock/ALPR G --· = Glasses/recording // T - = Tracker L ·-·· = Law enforcement // R ·-· = Ring/consumer S ··· = Surveillance cameras // V ···- = Raven W ·-- = WiFi detection #define MORSE_FREQ 800 // Hz — clean, distinct tone #define MORSE_DIT_MS 80 // dit duration #define MORSE_DAH_MS 240 // dah = 3x dit #define MORSE_GAP_MS 80 // inter-element gap = 1x dit #define MORSE_LEAD_MS 30 // tiny pre-delay for audio clarity // Play a Morse code element (dit or dah) static void fyMorseDit() { tone(BUZZER_PIN, MORSE_FREQ, MORSE_DIT_MS); delay(MORSE_DIT_MS + MORSE_GAP_MS); } static void fyMorseDah() { tone(BUZZER_PIN, MORSE_FREQ, MORSE_DAH_MS); delay(MORSE_DAH_MS + MORSE_GAP_MS); } // Play Morse pattern for a detection category static void fyMorseCategory(const char* category) { if (!fyBuzzerOn || !category) return; delay(MORSE_LEAD_MS); if (strcmp(category, "flock") == 0) { // F: ··-· fyMorseDit(); fyMorseDit(); fyMorseDah(); fyMorseDit(); } else if (strcmp(category, "glasses") == 0) { // G: --· fyMorseDah(); fyMorseDah(); fyMorseDit(); } else if (strcmp(category, "tracker") == 0) { // T: - fyMorseDah(); } else if (strcmp(category, "lawenf") == 0) { // L: ·-·· fyMorseDit(); fyMorseDah(); fyMorseDit(); fyMorseDit(); } else if (strcmp(category, "ring") == 0) { // R: ·-· fyMorseDit(); fyMorseDah(); fyMorseDit(); } else if (strcmp(category, "camera") == 0) { // S: ··· fyMorseDit(); fyMorseDit(); fyMorseDit(); } else if (strcmp(category, "raven") == 0) { // V: ···- fyMorseDit(); fyMorseDit(); fyMorseDit(); fyMorseDah(); } else if (strcmp(category, "axon") == 0) { // A: ·- (Axon ALPR — Flock's municipal replacement) fyMorseDit(); fyMorseDah(); } else if (strcmp(category, "vr_headset") == 0) { // E: · (single dit — VR headset, benign; quiet acknowledgement, not a threat) fyMorseDit(); } else { // Unknown: single long beep fyMorseDah(); fyMorseDah(); } noTone(BUZZER_PIN); } static void fyDetectBeep(const char* category = nullptr) { printf("[DANTIR] Detection alert! [%s]\n", category ? category : "?"); fyPixelAlertMode = true; fyPixelAlertStart = millis(); if (!fyBuzzerOn) return; if (category) { fyMorseCategory(category); } else { // Fallback: original crow caw for uncategorized fyCaw(400, 900, 100, 30); delay(60); fyCaw(450, 950, 100, 30); delay(60); fyCaw(900, 350, 200, 50); } } 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); } // ============================================================================ // NEOPIXEL FUNCTIONS // ============================================================================ static uint32_t fyHsvToRgb(uint16_t h, uint8_t s, uint8_t v) { uint8_t r, g, b; if (s == 0) { r = g = b = v; } else { uint8_t region = h / 43; uint8_t remainder = (h - (region * 43)) * 6; uint8_t p = (v * (255 - s)) >> 8; uint8_t q = (v * (255 - ((s * remainder) >> 8))) >> 8; uint8_t t = (v * (255 - ((s * (255 - remainder)) >> 8))) >> 8; switch (region) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; default: r = v; g = p; b = q; break; } } return fyPixel.Color(r, g, b); } // Idle: slow purple breathing (hue 270) static void fyPixelBreathing() { static unsigned long lastUpdate = 0; static float brightness = 0.0; static bool increasing = true; if (millis() - lastUpdate < 20) return; lastUpdate = millis(); if (increasing) { brightness += 0.02; if (brightness >= 1.0) { brightness = 1.0; increasing = false; } } else { brightness -= 0.02; if (brightness <= 0.1) { brightness = 0.1; increasing = true; } } uint32_t color = fyHsvToRgb(270, 255, (uint8_t)(FY_NEOPIXEL_BRIGHTNESS * brightness)); fyPixel.setPixelColor(0, color); fyPixel.show(); } // Detection: 3 rapid flashes red->pink->red (~750ms total) static void fyPixelDetection() { unsigned long elapsed = millis() - fyPixelAlertStart; int flashIdx = elapsed / 250; if (flashIdx >= 3) { fyPixelAlertMode = false; return; } uint16_t hue = (flashIdx == 1) ? 300 : 0; // pink middle, red bookends bool bright = ((elapsed % 250) < 150); uint8_t val = bright ? FY_NEOPIXEL_DETECTION_BRIGHTNESS : (FY_NEOPIXEL_BRIGHTNESS / 4); fyPixel.setPixelColor(0, fyHsvToRgb(hue, 255, val)); fyPixel.show(); } // Device in range: dim steady pink glow (hue 300) static void fyPixelHeartbeat() { fyPixel.setPixelColor(0, fyHsvToRgb(300, 255, 30)); fyPixel.show(); } // Dispatcher: called each loop iteration static void fyUpdatePixel() { if (fyPixelAlertMode) { fyPixelDetection(); } else if (fyDeviceInRange) { fyPixelHeartbeat(); } else { fyPixelBreathing(); } } // ============================================================================ // DETECTION HELPERS // ============================================================================ static bool checkBLEMACPrefix(const uint8_t* mac) { char mac_str[9]; snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); for (size_t i = 0; i < sizeof(ble_mac_prefixes)/sizeof(ble_mac_prefixes[0]); i++) { if (strncasecmp(mac_str, ble_mac_prefixes[i], 8) == 0) return true; } return false; } // Returns the category for a matching WiFi OUI, or nullptr if no match. static const char* checkWiFiMACPrefix(const uint8_t* mac) { char mac_str[9]; snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x", mac[0], mac[1], mac[2]); for (size_t i = 0; i < sizeof(wifi_mac_prefixes)/sizeof(wifi_mac_prefixes[0]); i++) { if (strncasecmp(mac_str, wifi_mac_prefixes[i].prefix, 8) == 0) return wifi_mac_prefixes[i].category; } return nullptr; } // ============================================================================ // WIFI PROMISCUOUS CALLBACK // ============================================================================ // Forward declarations for functions called by WiFi promiscuous callback static bool fyGPSIsFresh(); static int fyAddDetection(const char* mac, const char* name, int rssi, const char* method, const char* category = "unknown", bool isRaven = false, const char* ravenFW = ""); // Called by the WiFi driver for every frame on the AP's channel. // Probe requests from devices scanning (on ANY channel) are caught because // WiFi devices sweep all channels when probing. Beacons only on AP channel. // NOTE: Runs on the WiFi task — do NOT call delay()/tone() here. // Audio/LED alerts are deferred to the main loop via fyWifiAlertPending. static void fyWifiPromiscuousCB(void *buf, wifi_promiscuous_pkt_type_t type) { if (type != WIFI_PKT_MGMT) return; const wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf; const uint8_t *frame = pkt->payload; int len = pkt->rx_ctrl.sig_len; // Minimum 802.11 management header: 24 bytes // (FC:2 + Duration:2 + Addr1:6 + Addr2:6 + Addr3:6 + SeqCtrl:2) if (len < 24) return; // Frame Control byte 0: bits[3:2]=type, bits[7:4]=subtype uint8_t fc0 = frame[0]; uint8_t frame_type = (fc0 >> 2) & 0x03; uint8_t subtype = (fc0 >> 4) & 0x0F; if (frame_type != 0) return; // Not a management frame if (subtype != WIFI_MGMT_PROBE_REQ && subtype != WIFI_MGMT_BEACON) return; // Address 2 (source/transmitter MAC) at offset 10 const uint8_t *src_mac = &frame[10]; const char *wcat = checkWiFiMACPrefix(src_mac); if (!wcat) return; // Match! Build MAC string and register detection char mac_str[18]; snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", src_mac[0], src_mac[1], src_mac[2], src_mac[3], src_mac[4], src_mac[5]); const char *method = (subtype == WIFI_MGMT_PROBE_REQ) ? "wifi_probe" : "wifi_beacon"; int rssi = pkt->rx_ctrl.rssi; int idx = fyAddDetection(mac_str, "", rssi, method, wcat); if (idx >= 0) { if (fyDet[idx].count == 1) { // First sighting — new WiFi surveillance device fyWifiDetCount++; printf("[DANTIR] WiFi DETECTED [%s]: %s RSSI:%d [%s]\n", wcat, mac_str, rssi, method); // JSON serial output char gpsBuf[80] = ""; if (fyGPSIsFresh()) { snprintf(gpsBuf, sizeof(gpsBuf), ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", fyGPSLat, fyGPSLon, fyGPSAcc); } printf("{\"detection_method\":\"%s\",\"category\":\"%s\",\"protocol\":\"wifi\"," "\"mac_address\":\"%s\",\"rssi\":%d%s}\n", method, wcat, mac_str, rssi, gpsBuf); } // Defer buzzer/LED alert to main loop (can't call delay() here) fyDeviceInRange = true; fyLastDetTime = millis(); fyWifiAlertPending = true; } } // ============================================================================ // DETECTION HELPERS — NAME & MANUFACTURER // ============================================================================ // Returns category string if name matches, or nullptr static const char* checkDeviceName(const char* name) { if (!name || !name[0]) return nullptr; for (size_t i = 0; i < sizeof(device_name_entries)/sizeof(device_name_entries[0]); i++) { if (strcasestr(name, device_name_entries[i].pattern)) return device_name_entries[i].category; } return nullptr; } // Returns category string if manufacturer ID matches, or nullptr static const char* checkManufacturerID(uint16_t id) { for (size_t i = 0; i < sizeof(ble_manufacturer_entries)/sizeof(ble_manufacturer_entries[0]); i++) { if (ble_manufacturer_entries[i].id == id) return ble_manufacturer_entries[i].category; } return nullptr; } // ============================================================================ // 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 // ============================================================================ static bool fyGPSIsFresh() { return fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); } static void fyAttachGPS(FYDetection& d) { if (fyGPSIsFresh()) { d.hasGPS = true; d.gpsLat = fyGPSLat; d.gpsLon = fyGPSLon; d.gpsAcc = fyGPSAcc; } } // ============================================================================ // HARDWARE GPS PROCESSING // ============================================================================ static void fyProcessHardwareGPS() { // Read all available UART bytes into TinyGPSPlus parser while (fyGPSSerial.available()) { char c = fyGPSSerial.read(); fyGPS.encode(c); fyHWGPSLastChar = millis(); if (!fyHWGPSDetected) { fyHWGPSDetected = true; printf("[DANTIR] Hardware GPS module detected (NMEA data received)\n"); } } // Timeout: no NMEA data for 5s → module disconnected or absent if (fyHWGPSDetected && (millis() - fyHWGPSLastChar > GPS_HW_TIMEOUT_MS)) { if (fyGPSIsHardware) { printf("[DANTIR] Hardware GPS timeout — falling back to phone GPS\n"); } fyHWGPSDetected = false; fyHWGPSFix = false; fyHWGPSSats = 0; fyGPSIsHardware = false; } // Update satellite count whenever available if (fyGPS.satellites.isUpdated()) { fyHWGPSSats = fyGPS.satellites.value(); } // Update position when valid fix is available if (fyGPS.location.isUpdated() && fyGPS.location.isValid()) { if (!fyHWGPSFix) { printf("[DANTIR] First GPS fix acquired! Sats:%d Lat:%.6f Lon:%.6f\n", fyHWGPSSats, fyGPS.location.lat(), fyGPS.location.lng()); } fyHWGPSFix = true; fyGPSIsHardware = true; fyGPSLat = fyGPS.location.lat(); fyGPSLon = fyGPS.location.lng(); fyGPSAcc = fyGPS.hdop.isValid() ? (float)(fyGPS.hdop.hdop() * GPS_HDOP_SCALE) : 10.0f; fyGPSValid = true; fyGPSLastUpdate = millis(); } else if (fyHWGPSFix && fyGPS.location.isValid()) { // Keep updating timestamp while fix is held fyGPSLastUpdate = millis(); } } // ============================================================================ // DETECTION MANAGEMENT // ============================================================================ static int fyAddDetection(const char* mac, const char* name, int rssi, const char* method, const char* category, bool isRaven, 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]); // Track peak RSSI — strongest signal = closest approach to device if (rssi > fyDet[i].bestRSSI && fyGPSIsFresh()) { fyDet[i].bestRSSI = rssi; fyDet[i].bestGPSLat = fyGPSLat; fyDet[i].bestGPSLon = fyGPSLon; fyDet[i].bestGPSAcc = fyGPSAcc; fyDet[i].hasBestGPS = true; } 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; d.bestRSSI = rssi; // First sighting = initial best strncpy(d.method, method, sizeof(d.method) - 1); strncpy(d.category, category ? category : "unknown", sizeof(d.category) - 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 (first-seen position) fyAttachGPS(d); // Initialize best GPS to first-seen GPS if (d.hasGPS) { d.bestGPSLat = d.gpsLat; d.bestGPSLon = d.gpsLon; d.bestGPSAcc = d.gpsAcc; d.hasBestGPS = true; } int idx = fyDetCount++; xSemaphoreGive(fyMutex); return idx; } xSemaphoreGive(fyMutex); return -1; } // ============================================================================ // BLE SCANNING // ============================================================================ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { void onResult(NimBLEAdvertisedDevice* dev) override { NimBLEAddress addr = dev->getAddress(); std::string addrStr = addr.toString(); // Safe MAC byte extraction unsigned int m[6]; sscanf(addrStr.c_str(), "%02x:%02x:%02x:%02x:%02x:%02x", &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]); uint8_t mac[6] = {(uint8_t)m[0], (uint8_t)m[1], (uint8_t)m[2], (uint8_t)m[3], (uint8_t)m[4], (uint8_t)m[5]}; int rssi = dev->getRSSI(); std::string name = dev->haveName() ? dev->getName() : ""; bool detected = false; const char* method = ""; bool isRaven = false; const char* ravenFW = ""; const char* cat = nullptr; // 1. Check MAC prefix against known surveillance device OUIs (BLE) if (checkBLEMACPrefix(mac)) { detected = true; method = "mac_prefix"; cat = "flock"; // MAC prefixes are all Flock Safety OUIs } // 2. Check BLE device name patterns (returns category or nullptr) if (!detected && !name.empty()) { cat = checkDeviceName(name.c_str()); if (cat) { detected = true; method = "device_name"; } } // 3. Check BLE manufacturer company IDs (returns category or nullptr) 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]; cat = checkManufacturerID(code); if (cat) { detected = true; method = "ble_mfr_id"; break; } } } } // 4. Check Raven gunshot detector service UUIDs if (!detected) { char detUUID[41] = {0}; if (checkRavenUUID(dev, detUUID)) { detected = true; method = "raven_uuid"; cat = "raven"; isRaven = true; ravenFW = estimateRavenFW(dev); } } // 5. Refine glasses: Meta's mfr ID covers BOTH Ray-Ban Meta (camera/surveillance // glasses) and Quest/Oculus (VR headsets) — split by device name (different // threat models). Mirrors firmware-todo #6 / shared signatures library. if (cat && strcmp(cat, "glasses") == 0 && !name.empty()) { std::string lname = name; for (auto& c : lname) c = (char)tolower((unsigned char)c); if (lname.find("quest") != std::string::npos || lname.find("oculus") != std::string::npos) { cat = "vr_headset"; } } if (detected) { int idx = fyAddDetection(addrStr.c_str(), name.c_str(), rssi, method, cat ? cat : "unknown", isRaven, ravenFW); // Human-readable log const char* catStr = cat ? cat : "unknown"; printf("[DANTIR] DETECTED [%s]: %s %s RSSI:%d [%s] count:%d\n", catStr, addrStr.c_str(), name.c_str(), rssi, method, idx >= 0 ? fyDet[idx].count : 0); // JSON serial output (Flask-compatible format for live ingestion) // Build GPS fragment if available char gpsBuf[80] = ""; if (fyGPSIsFresh()) { snprintf(gpsBuf, sizeof(gpsBuf), ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", fyGPSLat, fyGPSLon, fyGPSAcc); } if (isRaven) { printf("{\"detection_method\":\"%s\",\"category\":\"%s\",\"protocol\":\"bluetooth_le\"," "\"mac_address\":\"%s\",\"device_name\":\"%s\"," "\"rssi\":%d,\"is_raven\":true,\"raven_fw\":\"%s\"%s}\n", method, catStr, addrStr.c_str(), name.c_str(), rssi, ravenFW, gpsBuf); } else { printf("{\"detection_method\":\"%s\",\"category\":\"%s\",\"protocol\":\"bluetooth_le\"," "\"mac_address\":\"%s\",\"device_name\":\"%s\"," "\"rssi\":%d%s}\n", method, catStr, addrStr.c_str(), name.c_str(), rssi, gpsBuf); } if (!fyCategoryAlerted(catStr)) { fyMarkCategoryAlerted(catStr); fyDetectBeep(catStr); fyLastHB = millis(); // Start heartbeat countdown AFTER the alert beep } fyDeviceInRange = true; fyLastDetTime = millis(); } } }; // ============================================================================ // JSON HELPER // ============================================================================ static void writeDetectionsJSON(AsyncResponseStream *resp) { resp->print("["); if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { for (int i = 0; i < fyDetCount; i++) { if (i > 0) resp->print(","); resp->printf( "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," "\"cat\":\"%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].category, fyDet[i].firstSeen, fyDet[i].lastSeen, fyDet[i].count, fyDet[i].isRaven ? "true" : "false", fyDet[i].ravenFW); // Append GPS if present (first-seen position) if (fyDet[i].hasGPS) { resp->printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", fyDet[i].gpsLat, fyDet[i].gpsLon, fyDet[i].gpsAcc); } // Append best GPS (peak-RSSI = closest approach position) if (fyDet[i].hasBestGPS) { resp->printf(",\"best_rssi\":%d,\"best_gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", fyDet[i].bestRSSI, fyDet[i].bestGPSLat, fyDet[i].bestGPSLon, fyDet[i].bestGPSAcc); } resp->print("}"); } xSemaphoreGive(fyMutex); } resp->print("]"); } // ============================================================================ // SESSION PERSISTENCE (SPIFFS) // ============================================================================ static void fySaveSession() { if (!fySpiffsReady || !fyMutex) return; if (xSemaphoreTake(fyMutex, pdMS_TO_TICKS(300)) != pdTRUE) return; File f = SPIFFS.open(FY_SESSION_FILE, "w"); if (!f) { xSemaphoreGive(fyMutex); return; } f.print("["); for (int i = 0; i < fyDetCount; i++) { if (i > 0) f.print(","); FYDetection& d = fyDet[i]; f.printf("{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\"," "\"cat\":\"%s\"," "\"first\":%lu,\"last\":%lu,\"count\":%d," "\"raven\":%s,\"fw\":\"%s\"", d.mac, d.name, d.rssi, d.method, d.category, d.firstSeen, d.lastSeen, d.count, d.isRaven ? "true" : "false", d.ravenFW); if (d.hasGPS) { f.printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", d.gpsLat, d.gpsLon, d.gpsAcc); } if (d.hasBestGPS) { f.printf(",\"best_rssi\":%d,\"best_gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", d.bestRSSI, d.bestGPSLat, d.bestGPSLon, d.bestGPSAcc); } f.print("}"); } f.print("]"); f.close(); fyLastSaveCount = fyDetCount; printf("[DANTIR] Session saved: %d detections\n", fyDetCount); xSemaphoreGive(fyMutex); } static void fyPromotePrevSession() { // Copy current session to prev_session on boot, then delete original // NOTE: SPIFFS.rename() is unreliable on ESP32 — use copy+delete instead if (!fySpiffsReady) return; if (!SPIFFS.exists(FY_SESSION_FILE)) { printf("[DANTIR] No prior session file to promote\n"); return; } File src = SPIFFS.open(FY_SESSION_FILE, "r"); if (!src) { printf("[DANTIR] Failed to open session file for promotion\n"); return; } String data = src.readString(); src.close(); if (data.length() == 0) { printf("[DANTIR] Session file empty, skipping promotion\n"); SPIFFS.remove(FY_SESSION_FILE); return; } // Write to prev_session (overwrite any existing) File dst = SPIFFS.open(FY_PREV_FILE, "w"); if (!dst) { printf("[DANTIR] Failed to create prev_session file\n"); return; } dst.print(data); dst.close(); // Delete the old session file so it doesn't get re-promoted next boot SPIFFS.remove(FY_SESSION_FILE); printf("[DANTIR] Prior session promoted: %d bytes\n", data.length()); } // Restore detections from prev_session into live array (append mode) // Called after promotion — loads prev_session data so dashboard survives reboots static void fyRestoreSession() { if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { printf("[DANTIR] No prev_session to restore\n"); return; } File f = SPIFFS.open(FY_PREV_FILE, "r"); if (!f) { printf("[DANTIR] Failed to open prev_session for restore\n"); return; } String data = f.readString(); f.close(); if (data.length() == 0) { printf("[DANTIR] prev_session empty, nothing to restore\n"); return; } JsonDocument doc; DeserializationError err = deserializeJson(doc, data); if (err || !doc.is()) { printf("[DANTIR] prev_session JSON parse failed: %s\n", err.c_str()); return; } int restored = 0; for (JsonObject d : doc.as()) { if (fyDetCount >= MAX_DETECTIONS) break; FYDetection& det = fyDet[fyDetCount]; memset(&det, 0, sizeof(FYDetection)); strlcpy(det.mac, d["mac"] | "", sizeof(det.mac)); strlcpy(det.name, d["name"] | "", sizeof(det.name)); det.rssi = d["rssi"] | 0; strlcpy(det.method, d["method"] | "", sizeof(det.method)); strlcpy(det.category, d["cat"] | "unknown", sizeof(det.category)); det.firstSeen = d["first"] | 0UL; det.lastSeen = d["last"] | 0UL; det.count = d["count"] | 1; det.isRaven = d["raven"] | false; strlcpy(det.ravenFW, d["fw"] | "", sizeof(det.ravenFW)); JsonObject gps = d["gps"]; if (gps && gps["lat"]) { det.gpsLat = gps["lat"] | 0.0; det.gpsLon = gps["lon"] | 0.0; det.gpsAcc = gps["acc"] | 0.0f; det.hasGPS = true; } if (d["best_rssi"].is()) { det.bestRSSI = d["best_rssi"] | 0; JsonObject bgps = d["best_gps"]; if (bgps && bgps["lat"]) { det.bestGPSLat = bgps["lat"] | 0.0; det.bestGPSLon = bgps["lon"] | 0.0; det.bestGPSAcc = bgps["acc"] | 0.0f; det.hasBestGPS = true; } } fyDetCount++; restored++; } // Mark as already saved so we don't immediately re-write the same data fyLastSaveCount = fyDetCount; printf("[DANTIR] Restored %d detections from prev_session\n", restored); } // ============================================================================ // KML EXPORT // ============================================================================ static void writeDetectionsKML(AsyncResponseStream *resp) { resp->print("\n" "\n\n" "Dantir Detections\n" "Surveillance device detections with GPS\n"); // Detection pin style resp->print("\n" "\n"); if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(300)) == pdTRUE) { for (int i = 0; i < fyDetCount; i++) { FYDetection& d = fyDet[i]; if (!d.hasGPS && !d.hasBestGPS) continue; // Skip detections without any GPS // Use best GPS (closest approach) if available, else first-seen GPS double pinLat = d.hasBestGPS ? d.bestGPSLat : d.gpsLat; double pinLon = d.hasBestGPS ? d.bestGPSLon : d.gpsLon; float pinAcc = d.hasBestGPS ? d.bestGPSAcc : d.gpsAcc; 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
" "Best RSSI: %d dBm
" "Count: %d
", d.method, d.rssi, d.hasBestGPS ? d.bestRSSI : d.rssi, d.count); if (d.isRaven) resp->printf("Raven FW: %s
", d.ravenFW); resp->printf("Accuracy: %.1f m", pinAcc); resp->print("]]>
\n"); resp->printf("%.8f,%.8f,0\n", pinLon, pinLat); resp->print("
\n"); } xSemaphoreGive(fyMutex); } resp->print("
\n
"); } // ============================================================================ // DASHBOARD HTML // ============================================================================ static const char FY_HTML[] PROGMEM = R"rawliteral( DANTIR

DANTIR

0
DETECTED
0
RAVEN
ON
BLE+WiFi
TAP
GPS
--
UPTIME
PROXIMITY RADAR
0 devices
Flock Ring Raven WiFi Glasses LawEnf Tracker Other
Scanning for surveillance devices...
BLE + WiFi promiscuous active
Loading prior session...
Loading patterns...

EXPORT DETECTIONS

Download current session data


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); } const char* gpsSrc = "none"; if (fyGPSIsHardware && fyHWGPSFix) gpsSrc = "hw"; else if (fyGPSIsFresh()) gpsSrc = "phone"; unsigned long upSec = millis() / 1000; int upH = upSec / 3600, upM = (upSec % 3600) / 60; char buf[512]; snprintf(buf, sizeof(buf), "{\"total\":%d,\"raven\":%d,\"ble\":\"active\",\"wifi\":\"active\"," "\"wifi_det\":%d," "\"gps_valid\":%s,\"gps_age\":%lu,\"gps_tagged\":%d," "\"gps_src\":\"%s\",\"gps_sats\":%d,\"gps_hw_detected\":%s," "\"device_lat\":%.8f,\"device_lon\":%.8f," "\"batt_pct\":%d,\"batt_v\":%.2f," "\"uptime\":\"%dh%02dm\"}", fyDetCount, raven, fyWifiDetCount, fyGPSIsFresh() ? "true" : "false", fyGPSValid ? (millis() - fyGPSLastUpdate) : 0UL, withGPS, gpsSrc, fyHWGPSSats, fyHWGPSDetected ? "true" : "false", fyGPSLat, fyGPSLon, fyBatteryPercent, fyBatteryVoltage, upH, upM); r->send(200, "application/json", buf); }); // API: Receive GPS from phone browser (ignored when hardware GPS has fix) fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) { if (fyHWGPSFix) { r->send(200, "application/json", "{\"status\":\"ignored\",\"reason\":\"hw_gps_active\"}"); return; } if (r->hasParam("lat") && r->hasParam("lon")) { fyGPSLat = r->getParam("lat")->value().toDouble(); fyGPSLon = r->getParam("lon")->value().toDouble(); fyGPSAcc = r->hasParam("acc") ? r->getParam("acc")->value().toFloat() : 0; fyGPSValid = true; fyGPSLastUpdate = millis(); fyGPSIsHardware = false; 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("{\"ble_macs\":["); for (size_t i = 0; i < sizeof(ble_mac_prefixes)/sizeof(ble_mac_prefixes[0]); i++) { if (i > 0) resp->print(","); resp->printf("\"%s\"", ble_mac_prefixes[i]); } resp->print("],\"wifi_macs\":["); for (size_t i = 0; i < sizeof(wifi_mac_prefixes)/sizeof(wifi_mac_prefixes[0]); i++) { if (i > 0) resp->print(","); resp->printf("{\"p\":\"%s\",\"c\":\"%s\"}", wifi_mac_prefixes[i].prefix, wifi_mac_prefixes[i].category); } resp->print("],\"names\":["); for (size_t i = 0; i < sizeof(device_name_entries)/sizeof(device_name_entries[0]); i++) { if (i > 0) resp->print(","); resp->printf("{\"p\":\"%s\",\"c\":\"%s\"}", device_name_entries[i].pattern, device_name_entries[i].category); } resp->print("],\"mfr\":["); for (size_t i = 0; i < sizeof(ble_manufacturer_entries)/sizeof(ble_manufacturer_entries[0]); i++) { if (i > 0) resp->print(","); resp->printf("{\"id\":%u,\"c\":\"%s\"}", ble_manufacturer_entries[i].id, ble_manufacturer_entries[i].category); } 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=\"dantir_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=\"dantir_detections.csv\""); resp->println("mac,name,rssi,method,first_seen_ms,last_seen_ms,count,is_raven,raven_fw,latitude,longitude,gps_accuracy,best_rssi,best_latitude,best_longitude,best_gps_accuracy"); if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { for (int i = 0; i < fyDetCount; i++) { FYDetection& d = fyDet[i]; resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\"", d.mac, d.name, d.rssi, d.method, d.firstSeen, d.lastSeen, d.count, d.isRaven ? "true" : "false", d.ravenFW); if (d.hasGPS) { resp->printf(",%.8f,%.8f,%.1f", d.gpsLat, d.gpsLon, d.gpsAcc); } else { resp->print(",,,"); } if (d.hasBestGPS) { resp->printf(",%d,%.8f,%.8f,%.1f\n", d.bestRSSI, d.bestGPSLat, d.bestGPSLon, d.bestGPSAcc); } else { resp->print(",,,\n"); // trailing newline included } } 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=\"dantir_detections.kml\""); writeDetectionsKML(resp); r->send(resp); }); // API: Prior session history (JSON) fyServer.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) { if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) { r->send(SPIFFS, FY_PREV_FILE, "application/json"); } else { r->send(200, "application/json", "[]"); } }); // API: Download prior session as JSON file fyServer.on("/api/history/json", HTTP_GET, [](AsyncWebServerRequest *r) { if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) { AsyncWebServerResponse *resp = r->beginResponse(SPIFFS, FY_PREV_FILE, "application/json"); resp->addHeader("Content-Disposition", "attachment; filename=\"dantir_prev_session.json\""); r->send(resp); } else { r->send(404, "application/json", "{\"error\":\"no prior session\"}"); } }); // API: Download prior session as KML (reads JSON 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; } 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=\"dantir_prev_session.kml\""); resp->print("\n" "\n\n" "Dantir 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()) { // Prefer best_gps (peak RSSI = closest approach), fall back to first-seen gps JsonObject bgps = d["best_gps"]; JsonObject gps = d["gps"]; JsonObject pinGPS = (bgps && bgps["lat"]) ? bgps : gps; if (!pinGPS || !pinGPS["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
", d["method"] | "?", d["rssi"] | 0); if (d["best_rssi"]) resp->printf("Best RSSI: %d
", d["best_rssi"] | 0); resp->printf("Count: %d", 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)(pinGPS["lon"] | 0.0), (double)(pinGPS["lat"] | 0.0)); resp->print("
\n"); placed++; } printf("[DANTIR] Prior session KML: %d placemarks\n", placed); } else { printf("[DANTIR] 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)); fyClearAlertedCategories(); fyDeviceInRange = false; xSemaphoreGive(fyMutex); } r->send(200, "application/json", "{\"status\":\"cleared\"}"); printf("[DANTIR] All detections cleared (session saved)\n"); }); fyServer.begin(); printf("[DANTIR] Web server started on port 80\n"); } // ============================================================================ // MAIN FUNCTIONS // ============================================================================ void setup() { Serial.begin(115200); delay(500); // Read buzzer setting from OUI-SPY NVS Preferences bzP; bzP.begin("ouispy-bz", true); fyBuzzerOn = bzP.getBool("on", true); bzP.end(); pinMode(BUZZER_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW); // Init NeoPixel fyPixel.begin(); fyPixel.setBrightness(FY_NEOPIXEL_BRIGHTNESS); fyPixel.clear(); fyPixel.show(); // Test flash: pink -> purple fyPixel.setPixelColor(0, fyPixel.Color(236, 72, 153)); // pink #ec4899 fyPixel.show(); delay(500); fyPixel.setPixelColor(0, fyPixel.Color(139, 92, 246)); // purple #8b5cf6 fyPixel.show(); delay(500); fyPixel.clear(); fyPixel.show(); fyMutex = xSemaphoreCreateMutex(); // Init hardware GPS UART (Seeed L76K on D6/D7) fyGPSSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); // Init SPIFFS for session persistence if (SPIFFS.begin(true)) { fySpiffsReady = true; printf("[DANTIR] SPIFFS ready\n"); // Promote last session to prev_session (backup), then restore into live array // This means: prev_session always has a backup, AND dashboard keeps all detections fyPromotePrevSession(); fyRestoreSession(); } else { printf("[DANTIR] SPIFFS init failed - no persistence\n"); } // Initialize battery monitoring fyReadBattery(); printf("\n========================================\n"); printf(" DANTIR Surveillance Counter-Watcher\n"); printf(" Buzzer: %s\n", fyBuzzerOn ? "ON" : "OFF"); printf(" GPS: auto-detect (L76K on D6/D7)\n"); printf(" Battery ADC: %s\n", BATTERY_ADC_PIN >= 0 ? "enabled" : "disabled (no voltage divider)"); printf("========================================\n"); // Init BLE scanner FIRST -- start scanning immediately NimBLEDevice::init(""); 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(BLE_SCAN_DURATION, false); fyLastBleScan = millis(); printf("[DANTIR] BLE scanning ACTIVE\n"); // 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("[DANTIR] AP: %s / %s\n", FY_AP_SSID, FY_AP_PASS); printf("[DANTIR] IP: %s\n", WiFi.softAPIP().toString().c_str()); // Enable WiFi promiscuous mode — captures management frames on AP channel // Probe requests from devices scanning other channels are still caught // because WiFi devices sweep all channels during active scanning esp_wifi_set_promiscuous_rx_cb(fyWifiPromiscuousCB); esp_wifi_set_promiscuous(true); printf("[DANTIR] WiFi promiscuous mode ACTIVE (ch %d)\n", WiFi.channel()); // Start web dashboard fySetupServer(); printf("[DANTIR] BLE: %d OUI prefixes, %d name patterns, %d mfr IDs, %d Raven UUIDs\n", (int)(sizeof(ble_mac_prefixes)/sizeof(ble_mac_prefixes[0])), (int)(sizeof(device_name_entries)/sizeof(device_name_entries[0])), (int)(sizeof(ble_manufacturer_entries)/sizeof(ble_manufacturer_entries[0])), (int)(sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]))); printf("[DANTIR] WiFi: %d OUI prefixes (promiscuous mode ACTIVE)\n", (int)(sizeof(wifi_mac_prefixes)/sizeof(wifi_mac_prefixes[0]))); printf("[DANTIR] Dashboard: http://192.168.4.1\n"); printf("[DANTIR] Ready - BLE scanning + WiFi promiscuous + AP dashboard\n\n"); } void loop() { fyProcessHardwareGPS(); fyUpdatePixel(); fyReadBattery(); // BLE scanning cycle if (millis() - fyLastBleScan >= BLE_SCAN_INTERVAL && !fyBLEScan->isScanning()) { fyBLEScan->start(BLE_SCAN_DURATION, false); fyLastBleScan = millis(); } if (!fyBLEScan->isScanning() && millis() - fyLastBleScan > BLE_SCAN_DURATION * 1000) { fyBLEScan->clearResults(); } // WiFi detection alert (deferred from promiscuous callback — can't buzz there) if (fyWifiAlertPending) { fyWifiAlertPending = false; if (!fyCategoryAlerted("ring")) { fyMarkCategoryAlerted("ring"); fyDetectBeep("ring"); // WiFi OUIs are all Ring/Blink fyLastHB = millis(); } } // Heartbeat tracking — runs on clean 10s cadence, independent of re-detections if (fyDeviceInRange) { if (millis() - fyLastHB >= 10000) { fyHeartbeat(); fyLastHB = millis(); } if (millis() - fyLastDetTime >= 30000) { printf("[DANTIR] Device out of range - stopping heartbeat\n"); fyDeviceInRange = false; fyClearAlertedCategories(); } } // Auto-save session to SPIFFS every 15s if detections changed // Also triggers an early save 5s after first detection to minimize loss on power-cycle if (fySpiffsReady && millis() - fyLastSave >= FY_SAVE_INTERVAL) { if (fyDetCount > 0 && fyDetCount != fyLastSaveCount) { fySaveSession(); } fyLastSave = millis(); } else if (fySpiffsReady && fyDetCount > 0 && fyLastSaveCount == 0 && millis() - fyLastSave >= 5000) { // Quick first-save: persist within 5s of first detection fySaveSession(); fyLastSave = millis(); } delay(100); }