From d47dd1f435034c48ff4b35c9799e9f8ed510e018 Mon Sep 17 00:00:00 2001 From: rpriven Date: Thu, 19 Feb 2026 23:06:19 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Dantir=20surveil?= =?UTF-8?q?lance=20counter-watcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone ESP32-S3 firmware forked from OUI Spy Unified Blue (Flock-You mode). Expanded BLE detection with Ring/Amazon patterns, WiFi OUI table ready for promiscuous mode in Phase 2. - Rebranded: AP "dantir"/"dantir123", dashboard, logs, exports - BLE: 20 Flock Safety OUIs, 5 name patterns, 2 mfr IDs (XUNTONG + Amazon), 8 Raven UUIDs - WiFi: 13 Ring/Blink OUI prefixes defined (promiscuous callback pending) - MIT license with lukeswitz/oui-spy attribution Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 + LICENSE | 22 + partitions.csv | 5 + platformio.ini | 41 ++ src/main.cpp | 1282 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1356 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 partitions.csv create mode 100644 platformio.ini create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5142be --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio +.vscode +*.swp +*.swo +*~ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d520de --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 rpriven +Based on OUI Spy by lukeswitz (https://github.com/lukeswitz/oui-spy) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..b3ec3c3 --- /dev/null +++ b/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/platformio.ini b/platformio.ini new file mode 100644 index 0000000..daf2dda --- /dev/null +++ b/platformio.ini @@ -0,0 +1,41 @@ +; ============================================================================ +; Dantir — Surveillance Counter-Watcher +; "Go flock yourself." +; Board: Seeed XIAO ESP32-S3 (N8R8) +; ============================================================================ + +[env:seeed_xiao_esp32s3] +platform = espressif32@^6.3.0 +board = seeed_xiao_esp32s3 +framework = arduino + +; Build options +build_flags = + -DCORE_DEBUG_LEVEL=3 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue + -DCONFIG_BT_NIMBLE_ENABLED=1 + +; Upload options +upload_speed = 921600 +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder + +; Libraries +lib_deps = + h2zero/NimBLE-Arduino@^1.4.0 + mathieucarbou/ESP Async WebServer@^3.0.6 + adafruit/Adafruit NeoPixel@^1.12.0 + bblanchon/ArduinoJson@^7.0.4 + mikalhart/TinyGPSPlus@^1.1.0 + +; Board configuration +board_build.arduino.memory_type = qio_opi +board_build.partitions = partitions.csv +board_build.filesystem = littlefs + +; USB CDC configuration +board_build.f_cpu = 240000000L +board_build.f_flash = 80000000L +board_build.flash_mode = qio diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..16456a9 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1282 @@ +// ============================================================================ +// 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/lukeswitz/oui-spy +// +// 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 + +// ============================================================================ +// 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 + +// 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", "04:0d:84", + "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", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea" +}; + +// BLE device name patterns (matched case-insensitive substring) +static const char* device_name_patterns[] = { + "FS Ext Battery", + "Penguin", + "Flock", + "Pigvision", + "Ring" // Ring cameras/doorbells during BLE setup +}; + +// BLE Manufacturer Company IDs +static const uint16_t ble_manufacturer_ids[] = { + 0x09C8, // XUNTONG — Flock Safety (via wgreenberg/flock-you) + 0x0171 // Amazon — Ring doorbells, Echo, security devices +}; + +// ============================================================================ +// DETECTION PATTERNS — WiFi (promiscuous mode) +// ============================================================================ +// These OUIs are checked against source MACs in WiFi management frames +// (probe requests, beacon frames) captured via promiscuous callback. + +// Ring LLC OUI prefixes (IEEE registered, verified Feb 2026) +static const char* wifi_mac_prefixes[] = { + // Ring LLC + "00:b4:63", "18:7f:88", "24:2b:d6", "34:3e:a4", + "54:e0:19", "5c:47:5e", "64:9a:63", "90:48:6c", + "9c:76:13", "ac:9f:c3", "c4:db:ad", "cc:3b:fb", + // Blink by Amazon (Ring's sister brand) + "70:ad:43" +}; + +// ============================================================================ +// 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]; + 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 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 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 + +// 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; + +// ============================================================================ +// 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"); +} + +static void fyDetectBeep() { + printf("[DANTIR] Detection alert!\n"); + fyPixelAlertMode = true; + fyPixelAlertStart = millis(); + 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); +} + +// ============================================================================ +// 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; +} + +static bool 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], 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; + } +} + +// ============================================================================ +// 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, 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(); + + // 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 = ""; + + // 1. Check MAC prefix against known surveillance device OUIs (BLE) + if (checkBLEMACPrefix(mac)) { + detected = true; + method = "mac_prefix"; + } + + // 2. Check BLE device name patterns + if (!detected && !name.empty() && checkDeviceName(name.c_str())) { + detected = true; + method = "device_name"; + } + + // 3. 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; + } + } + } + } + + // 4. 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("[DANTIR] 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) { + fyTriggered = true; + fyDetectBeep(); + } + 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("[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()); +} + +// ============================================================================ +// 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) 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( + + +DANTIR + +

DANTIR

Surveillance Counter-Watcher • BLE + WiFi + 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); + } + const char* gpsSrc = "none"; + if (fyGPSIsHardware && fyHWGPSFix) gpsSrc = "hw"; + else if (fyGPSIsFresh()) gpsSrc = "phone"; + char buf[320]; + snprintf(buf, sizeof(buf), + "{\"total\":%d,\"raven\":%d,\"ble\":\"active\"," + "\"gps_valid\":%s,\"gps_age\":%lu,\"gps_tagged\":%d," + "\"gps_src\":\"%s\",\"gps_sats\":%d,\"gps_hw_detected\":%s}", + fyDetCount, raven, + fyGPSIsFresh() ? "true" : "false", + fyGPSValid ? (millis() - fyGPSLastUpdate) : 0UL, + withGPS, + gpsSrc, fyHWGPSSats, + fyHWGPSDetected ? "true" : "false"); + 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("\"%s\"", wifi_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=\"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"); + 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=\"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()) { + 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("[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)); + fyTriggered = false; + 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 before we start a new one + fyPromotePrevSession(); + } else { + printf("[DANTIR] SPIFFS init failed - no persistence\n"); + } + + 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("========================================\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()); + + // 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_patterns)/sizeof(device_name_patterns[0])), + (int)(sizeof(ble_manufacturer_ids)/sizeof(ble_manufacturer_ids[0])), + (int)(sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]))); + printf("[DANTIR] WiFi: %d OUI prefixes (promiscuous mode pending)\n", + (int)(sizeof(wifi_mac_prefixes)/sizeof(wifi_mac_prefixes[0]))); + printf("[DANTIR] Dashboard: http://192.168.4.1\n"); + printf("[DANTIR] Ready - no WiFi connection needed, BLE + AP only\n\n"); +} + +void loop() { + fyProcessHardwareGPS(); + fyUpdatePixel(); + + // 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("[DANTIR] 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); +}