// ============================================================================ // 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 #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 "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; // ============================================================================ // 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); // 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 // ============================================================================ 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; } } // ============================================================================ // 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 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 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\",\"protocol\":\"bluetooth_le\"," "\"mac_address\":\"%s\",\"device_name\":\"%s\"," "\"rssi\":%d,\"is_raven\":true,\"raven_fw\":\"%s\"%s}\n", method, addrStr.c_str(), name.c_str(), rssi, ravenFW, gpsBuf); } else { printf("{\"detection_method\":\"%s\",\"protocol\":\"bluetooth_le\"," "\"mac_address\":\"%s\",\"device_name\":\"%s\"," "\"rssi\":%d%s}\n", method, addrStr.c_str(), name.c_str(), rssi, gpsBuf); } 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(200)) == 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) // ============================================================================ 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\"," "\"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); if (d.hasGPS) { f.printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", d.gpsLat, d.gpsLon, d.gpsAcc); } f.print("}"); } f.print("]"); f.close(); fyLastSaveCount = fyDetCount; printf("[FLOCK-YOU] 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("[FLOCK-YOU] No prior session file to promote\n"); return; } File src = SPIFFS.open(FY_SESSION_FILE, "r"); if (!src) { printf("[FLOCK-YOU] Failed to open session file for promotion\n"); return; } String data = src.readString(); src.close(); if (data.length() == 0) { printf("[FLOCK-YOU] 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("[FLOCK-YOU] 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("[FLOCK-YOU] Prior session promoted: %d bytes\n", data.length()); } // ============================================================================ // 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(300)) == 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 fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) { 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(); 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(200)) == 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) 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=\"flockyou_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=\"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(); // 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 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("[FLOCK-YOU] 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("[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 - no WiFi connection needed, BLE + AP only\n\n"); } void loop() { // 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(); } // 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; } } // 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); }