From d255408bb869f8c9b69bf5c05837e3c9c4a01630 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 20:38:09 -0700 Subject: [PATCH 1/9] Add BLE GATT server, serial host detection, and companion mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable DeFlock mobile app connectivity via BLE GATT notifications, and desktop host detection via USB serial heartbeat. When a companion is connected, WiFi AP is disabled to free radio bandwidth and BLE scan duty cycle is increased for better detection performance. - BLE GATT server advertising service UUID a1b2c3d4-e5f6-7890-abcd-ef0123456789 with TX characteristic (NOTIFY) for streaming detection JSON - Chunked BLE notification sender respecting negotiated MTU - "event":"detection" field added to JSON output for DeFlock parser - Serial host detection via heartbeat timeout (5s) - Companion mode: WiFi AP off + scan duration 2s→3s when connected - Scan interval/duration converted from #define to mutable variables Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 167 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 20 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 42d68db..4d2bee8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,9 +43,9 @@ #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 +// BLE scanning (mutable — companion mode increases scan duty cycle) +static int fyBleScanDuration = 2; // seconds per scan +static unsigned long fyBleScanInterval = 3000; // ms between scans // Detection storage #define MAX_DETECTIONS 200 @@ -144,6 +144,19 @@ static unsigned long fyLastHB = 0; static NimBLEScan* fyBLEScan = NULL; static AsyncWebServer fyServer(80); +// BLE GATT server (DeFlock app connectivity) +#define FY_SERVICE_UUID "a1b2c3d4-e5f6-7890-abcd-ef0123456789" +#define FY_TX_CHAR_UUID "a1b2c3d4-e5f6-7890-abcd-ef01234567aa" +static NimBLEServer* fyBLEServer = NULL; +static NimBLECharacteristic* fyTxChar = NULL; +static volatile bool fyBLEClientConnected = false; +static volatile uint16_t fyNegotiatedMTU = 23; + +// Serial host detection (USB heartbeat from DeFlock desktop) +static volatile bool fySerialHostConnected = false; +static unsigned long fyLastSerialHeartbeat = 0; +#define FY_SERIAL_TIMEOUT_MS 5000 + // Phone GPS state (updated via browser Geolocation API -> /api/gps) static double fyGPSLat = 0; static double fyGPSLon = 0; @@ -368,6 +381,74 @@ static int fyAddDetection(const char* mac, const char* name, int rssi, return -1; } +// ============================================================================ +// BLE GATT SERVER (DeFlock companion connectivity) +// ============================================================================ + +// Forward declaration +static void fyOnCompanionChange(); + +class FYServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { + fyBLEClientConnected = true; + printf("[FLOCK-YOU] BLE client connected\n"); + fyOnCompanionChange(); + } + void onDisconnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { + fyBLEClientConnected = false; + fyNegotiatedMTU = 23; + printf("[FLOCK-YOU] BLE client disconnected\n"); + NimBLEDevice::startAdvertising(); + fyOnCompanionChange(); + } + void onMTUChange(uint16_t mtu, ble_gap_conn_desc* desc) override { + fyNegotiatedMTU = mtu; + printf("[FLOCK-YOU] MTU negotiated: %u\n", mtu); + } +}; + +static void fySendBLE(const char* data, size_t len) { + if (!fyBLEClientConnected || !fyTxChar) return; + uint16_t chunkSize = fyNegotiatedMTU - 3; + if (chunkSize < 1) chunkSize = 1; + if (len <= chunkSize) { + fyTxChar->setValue((const uint8_t*)data, len); + fyTxChar->notify(); + } else { + size_t offset = 0; + while (offset < len) { + size_t remaining = len - offset; + size_t send = remaining < chunkSize ? remaining : chunkSize; + fyTxChar->setValue((const uint8_t*)(data + offset), send); + fyTxChar->notify(); + offset += send; + } + } +} + +// ============================================================================ +// COMPANION MODE (WiFi AP vs BLE/serial) +// ============================================================================ + +static void fyOnCompanionChange() { + if (fyBLEClientConnected || fySerialHostConnected) { + // Companion mode — disable WiFi AP, boost BLE scanning + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_OFF); + fyBleScanDuration = 3; + printf("[FLOCK-YOU] Companion mode: WiFi AP OFF, scan duration %ds\n", + fyBleScanDuration); + } else { + // Standalone mode — re-enable WiFi AP and web dashboard + WiFi.mode(WIFI_AP); + delay(100); + WiFi.softAP(FY_AP_SSID, FY_AP_PASS); + fyBleScanDuration = 2; + printf("[FLOCK-YOU] Standalone mode: WiFi AP ON (%s), scan duration %ds\n", + FY_AP_SSID, fyBleScanDuration); + } +} + // ============================================================================ // BLE SCANNING // ============================================================================ @@ -440,24 +521,34 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { 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 + // JSON output — build into buffer for serial + BLE char gpsBuf[80] = ""; if (fyGPSIsFresh()) { snprintf(gpsBuf, sizeof(gpsBuf), ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", fyGPSLat, fyGPSLon, fyGPSAcc); } + char jsonBuf[512]; + int jsonLen; 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); + jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), + "{\"event\":\"detection\",\"detection_method\":\"%s\"," + "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," + "\"device_name\":\"%s\",\"rssi\":%d," + "\"is_raven\":true,\"raven_fw\":\"%s\"%s}", + 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); + jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), + "{\"event\":\"detection\",\"detection_method\":\"%s\"," + "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," + "\"device_name\":\"%s\",\"rssi\":%d%s}", + method, addrStr.c_str(), name.c_str(), rssi, gpsBuf); + } + printf("%s\n", jsonBuf); + // Append newline for BLE framing and send + if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { + jsonBuf[jsonLen] = '\n'; + fySendBLE(jsonBuf, jsonLen + 1); } if (!fyTriggered) { @@ -929,8 +1020,11 @@ void setup() { printf(" Buzzer: %s\n", fyBuzzerOn ? "ON" : "OFF"); printf("========================================\n"); - // Init BLE scanner FIRST -- start scanning immediately - NimBLEDevice::init(""); + // Init BLE with device name and large MTU for GATT notifications + NimBLEDevice::init("flockyou"); + NimBLEDevice::setMTU(512); + + // BLE scanner setup fyBLEScan = NimBLEDevice::getScan(); fyBLEScan->setAdvertisedDeviceCallbacks(new FYBLECallbacks()); fyBLEScan->setActiveScan(true); @@ -938,10 +1032,27 @@ void setup() { fyBLEScan->setWindow(99); // Kick off the first scan right away - fyBLEScan->start(BLE_SCAN_DURATION, false); + fyBLEScan->start(fyBleScanDuration, false); fyLastBleScan = millis(); printf("[FLOCK-YOU] BLE scanning ACTIVE\n"); + // BLE GATT server — DeFlock app connectivity + fyBLEServer = NimBLEDevice::createServer(); + fyBLEServer->setCallbacks(new FYServerCallbacks()); + NimBLEService* pService = fyBLEServer->createService(FY_SERVICE_UUID); + fyTxChar = pService->createCharacteristic( + FY_TX_CHAR_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + pService->start(); + + NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); + pAdv->addServiceUUID(FY_SERVICE_UUID); + pAdv->setName("flockyou"); + pAdv->setScanResponse(true); + pAdv->start(); + printf("[FLOCK-YOU] BLE GATT server advertising (service %s)\n", FY_SERVICE_UUID); + // Crow calls play WHILE BLE is already scanning fyBootBeep(); @@ -957,17 +1068,33 @@ void setup() { 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"); + printf("[FLOCK-YOU] Ready - BLE GATT + AP mode\n\n"); } void loop() { + // Serial host detection (heartbeat from DeFlock desktop app) + if (Serial.available()) { + while (Serial.available()) Serial.read(); // drain buffer + fyLastSerialHeartbeat = millis(); + if (!fySerialHostConnected) { + fySerialHostConnected = true; + printf("[FLOCK-YOU] Serial host connected\n"); + fyOnCompanionChange(); + } + } else if (fySerialHostConnected && + millis() - fyLastSerialHeartbeat >= FY_SERIAL_TIMEOUT_MS) { + fySerialHostConnected = false; + printf("[FLOCK-YOU] Serial host disconnected (timeout)\n"); + fyOnCompanionChange(); + } + // BLE scanning cycle - if (millis() - fyLastBleScan >= BLE_SCAN_INTERVAL && !fyBLEScan->isScanning()) { - fyBLEScan->start(BLE_SCAN_DURATION, false); + if (millis() - fyLastBleScan >= fyBleScanInterval && !fyBLEScan->isScanning()) { + fyBLEScan->start(fyBleScanDuration, false); fyLastBleScan = millis(); } - if (!fyBLEScan->isScanning() && millis() - fyLastBleScan > BLE_SCAN_DURATION * 1000) { + if (!fyBLEScan->isScanning() && millis() - fyLastBleScan > (unsigned long)fyBleScanDuration * 1000) { fyBLEScan->clearResults(); } From a8882b44f23a9fd6866cd3825ea21a7fe400aea9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 20:38:51 -0700 Subject: [PATCH 2/9] Add OUI prefixes for Flock WiFi cameras, Flock Safety direct, and ShotSpotter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand MAC prefix detection with entries sourced from: - Flock WiFi (Liteon/USI): f4:6a:dd, f8:a2:d6, e0:0a:f6, 00:f4:8d, d0:39:57, e8:d0:fc — contract manufacturer OUIs (Liteon Technology and USI/Universal Scientific Industrial) identified via the OUI-SPY firmware ecosystem table and cross-referenced against the IEEE OUI registry. These manufacturers produce Flock Safety's WiFi-enabled camera hardware. - Flock Safety direct: b4:1e:52 — registered directly to "Flock Safety" in the IEEE OUI database (MA-L assignment). This is their own prefix rather than a contract manufacturer's. - SoundThinking/ShotSpotter: d4:11:d6 — registered to "SoundThinking Inc" (formerly ShotSpotter) in the IEEE OUI database. Their acoustic gunshot detection sensors use BLE for local diagnostics and provisioning. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 42d68db..0f3a976 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -63,9 +63,24 @@ static const char* 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 + // Flock WiFi devices (Liteon Technology + USI) + // These OUIs belong to Liteon Technology and USI (Universal Scientific + // Industrial) — contract manufacturers that produce Flock Safety's + // WiFi-enabled cameras. Sourced from OUI-SPY firmware ecosystem table + // cross-referenced with IEEE OUI registry and field observations. "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" + "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea", + "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", + "e8:d0:fc", + // Flock Safety (direct IEEE registration) + // b4:1e:52 is registered directly to "Flock Safety" in the IEEE OUI + // database — this is their own prefix, not a contract manufacturer. + "b4:1e:52", + // ShotSpotter / SoundThinking + // d4:11:d6 is registered to SoundThinking (formerly ShotSpotter) in + // the IEEE OUI database. Their acoustic gunshot detection sensors use + // BLE for local diagnostics and provisioning. + "d4:11:d6" }; // BLE device name patterns (matched case-insensitive substring) From 00afc2826a006fa49f9e8aa49af0a0b2660982d2 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:03:19 -0700 Subject: [PATCH 3/9] Defer companion mode switch from BLE callbacks to loop() BLE server callbacks run on the NimBLE host task, not the Arduino loop task. Calling WiFi state changes and delay() from that context can stall BLE processing or trip watchdogs, and mutating scan duration creates a cross-task data race. Fix: callbacks now just set a volatile pending flag. The actual WiFi/scan changes are applied in loop() where they're safe. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 4d2bee8..da7e14d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -157,6 +157,10 @@ static volatile bool fySerialHostConnected = false; static unsigned long fyLastSerialHeartbeat = 0; #define FY_SERIAL_TIMEOUT_MS 5000 +// Deferred companion mode switch — BLE callbacks set this flag, +// loop() applies the WiFi/scan changes in the Arduino task context. +static volatile bool fyCompanionChangePending = false; + // Phone GPS state (updated via browser Geolocation API -> /api/gps) static double fyGPSLat = 0; static double fyGPSLon = 0; @@ -385,21 +389,16 @@ static int fyAddDetection(const char* mac, const char* name, int rssi, // BLE GATT SERVER (DeFlock companion connectivity) // ============================================================================ -// Forward declaration -static void fyOnCompanionChange(); - class FYServerCallbacks : public NimBLEServerCallbacks { void onConnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { fyBLEClientConnected = true; - printf("[FLOCK-YOU] BLE client connected\n"); - fyOnCompanionChange(); + fyCompanionChangePending = true; } void onDisconnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { fyBLEClientConnected = false; fyNegotiatedMTU = 23; - printf("[FLOCK-YOU] BLE client disconnected\n"); NimBLEDevice::startAdvertising(); - fyOnCompanionChange(); + fyCompanionChangePending = true; } void onMTUChange(uint16_t mtu, ble_gap_conn_desc* desc) override { fyNegotiatedMTU = mtu; @@ -1078,13 +1077,17 @@ void loop() { fyLastSerialHeartbeat = millis(); if (!fySerialHostConnected) { fySerialHostConnected = true; - printf("[FLOCK-YOU] Serial host connected\n"); - fyOnCompanionChange(); + fyCompanionChangePending = true; } } else if (fySerialHostConnected && millis() - fyLastSerialHeartbeat >= FY_SERIAL_TIMEOUT_MS) { fySerialHostConnected = false; - printf("[FLOCK-YOU] Serial host disconnected (timeout)\n"); + fyCompanionChangePending = true; + } + + // Apply deferred companion mode switch (from BLE callbacks or serial detection) + if (fyCompanionChangePending) { + fyCompanionChangePending = false; fyOnCompanionChange(); } From 06d734992f466788527c26d524395969e02aede9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:46:51 -0700 Subject: [PATCH 4/9] Always emit is_raven and raven_fw in detection JSON Collapse the two-branch snprintf into a single call so every detection message includes is_raven (true/false) and raven_fw, making the format self-describing regardless of device type. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index da7e14d..08e1f33 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -528,21 +528,13 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { fyGPSLat, fyGPSLon, fyGPSAcc); } char jsonBuf[512]; - int jsonLen; - if (isRaven) { - jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), - "{\"event\":\"detection\",\"detection_method\":\"%s\"," - "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," - "\"device_name\":\"%s\",\"rssi\":%d," - "\"is_raven\":true,\"raven_fw\":\"%s\"%s}", - method, addrStr.c_str(), name.c_str(), rssi, ravenFW, gpsBuf); - } else { - jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), - "{\"event\":\"detection\",\"detection_method\":\"%s\"," - "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," - "\"device_name\":\"%s\",\"rssi\":%d%s}", - method, addrStr.c_str(), name.c_str(), rssi, gpsBuf); - } + int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), + "{\"event\":\"detection\",\"detection_method\":\"%s\"," + "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," + "\"device_name\":\"%s\",\"rssi\":%d," + "\"is_raven\":%s,\"raven_fw\":\"%s\"%s}", + method, addrStr.c_str(), name.c_str(), rssi, + isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); printf("%s\n", jsonBuf); // Append newline for BLE framing and send if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { From c9afc3438de40b79c436289c044ba312f0edecdc Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:58:54 -0700 Subject: [PATCH 5/9] Split MAC prefixes by vendor/confidence to reduce false positives Address Copilot review: contract manufacturer OUIs (Liteon/USI) are now in a separate flock_mfr_mac_prefixes[] array emitting "mac_prefix_mfr" as the detection method. SoundThinking/ShotSpotter gets its own array and "mac_prefix_soundthinking" method. Low-confidence detections (mfr OUIs) suppress buzzer/heartbeat but still emit JSON events so consumers can apply their own thresholds. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 115 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 37 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0f3a976..6ce4b03 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -58,28 +58,32 @@ // DETECTION PATTERNS // ============================================================================ -// Known Flock Safety MAC address prefixes (OUIs) -static const char* mac_prefixes[] = { +// 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 (Liteon Technology + USI) - // These OUIs belong to Liteon Technology and USI (Universal Scientific - // Industrial) — contract manufacturers that produce Flock Safety's - // WiFi-enabled cameras. Sourced from OUI-SPY firmware ecosystem table - // cross-referenced with IEEE OUI registry and field observations. + // 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", - "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", - "e8:d0:fc", // Flock Safety (direct IEEE registration) - // b4:1e:52 is registered directly to "Flock Safety" in the IEEE OUI - // database — this is their own prefix, not a contract manufacturer. - "b4:1e:52", - // ShotSpotter / SoundThinking - // d4:11:d6 is registered to SoundThinking (formerly ShotSpotter) in - // the IEEE OUI database. Their acoustic gunshot detection sensors use - // BLE for local diagnostics and provisioning. + "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" }; @@ -251,11 +255,23 @@ static void fyHeartbeat() { // DETECTION HELPERS // ============================================================================ -static bool checkMACPrefix(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(mac_prefixes)/sizeof(mac_prefixes[0]); i++) { - if (strncasecmp(mac_str, mac_prefixes[i], 8) == 0) return true; +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; } @@ -392,34 +408,45 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { 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]}; + // 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 MAC prefix against known Flock Safety OUIs - if (checkMACPrefix(mac)) { + // 1. Check Flock Safety direct OUIs (high confidence) + if (checkFlockMAC(macPrefix)) { detected = true; method = "mac_prefix"; } - // 2. Check BLE device name patterns + // 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"; } - // 3. Check BLE manufacturer company IDs (from wgreenberg/flock-you) + // 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); @@ -435,7 +462,7 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { } } - // 4. Check Raven gunshot detector service UUIDs + // 6. Check Raven gunshot detector service UUIDs if (!detected) { char detUUID[41] = {0}; if (checkRavenUUID(dev, detUUID)) { @@ -475,11 +502,13 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { method, addrStr.c_str(), name.c_str(), rssi, gpsBuf); } - if (!fyTriggered) { + if (!fyTriggered && highConfidence) { fyTriggered = true; fyDetectBeep(); } - fyDeviceInRange = true; + if (highConfidence) { + fyDeviceInRange = true; + } fyLastDetTime = millis(); fyLastHB = millis(); } @@ -685,7 +714,9 @@ function card(d){return '
'+d.mac+(d.name?'r.json()).then(d=>{H=d;let el=document.getElementById('hL');if(!H.length){el.innerHTML='
No prior session data
';return;} H.sort((a,b)=>b.last-a.last);el.innerHTML='
'+H.length+' detections from prior session
'+H.map(card).join('');window._hL=1;}).catch(()=>{document.getElementById('hL').innerHTML='
No prior session data
';});} function loadPat(){fetch('/api/patterns').then(r=>r.json()).then(p=>{let h=''; -h+='

MAC Prefixes ('+p.macs.length+')

'+p.macs.map(m=>''+m+'').join('')+'
'; +h+='

Flock MAC Prefixes ('+p.macs.length+')

'+p.macs.map(m=>''+m+'').join('')+'
'; +h+='

Contract Mfr MACs ('+p.macs_mfr.length+')

'+p.macs_mfr.map(m=>''+m+'').join('')+'
'; +h+='

SoundThinking MACs ('+p.macs_soundthinking.length+')

'+p.macs_soundthinking.map(m=>''+m+'').join('')+'
'; h+='

BLE Device Names ('+p.names.length+')

'+p.names.map(n=>''+n+'').join('')+'
'; h+='

BLE Manufacturer IDs ('+p.mfr.length+')

'+p.mfr.map(m=>'0x'+m.toString(16).toUpperCase().padStart(4,'0')+'').join('')+'
'; h+='

Raven UUIDs ('+p.raven.length+')

'+p.raven.map(u=>''+u+'').join('')+'
'; @@ -771,9 +802,19 @@ static void fySetupServer() { fyServer.on("/api/patterns", HTTP_GET, [](AsyncWebServerRequest *r) { AsyncResponseStream *resp = r->beginResponseStream("application/json"); resp->print("{\"macs\":["); - for (size_t i = 0; i < sizeof(mac_prefixes)/sizeof(mac_prefixes[0]); i++) { + 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\"", mac_prefixes[i]); + 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++) { From 064b43c1c84a8e31b142c9441a18af6f5e7182fa Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Feb 2026 14:16:53 -0700 Subject: [PATCH 6/9] Fix heartbeat timer leak for low-confidence detections, bump method buffer Low-confidence mac_prefix_mfr hits no longer update fyLastDetTime/fyLastHB, preventing them from keeping the heartbeat alive after a high-confidence device leaves range. Bump FYDetection::method from 24 to 32 bytes so "mac_prefix_soundthinking" (23 chars) has headroom. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6ce4b03..5a235dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -133,7 +133,7 @@ struct FYDetection { char mac[18]; char name[48]; int rssi; - char method[24]; + char method[32]; unsigned long firstSeen; unsigned long lastSeen; int count; @@ -508,9 +508,9 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { } if (highConfidence) { fyDeviceInRange = true; + fyLastDetTime = millis(); + fyLastHB = millis(); } - fyLastDetTime = millis(); - fyLastHB = millis(); } } }; From 919384ccd54eddbed96355bc914a98e6f482936c Mon Sep 17 00:00:00 2001 From: agstewart Date: Sat, 7 Mar 2026 16:26:18 -0600 Subject: [PATCH 7/9] Added native unit test framework for detection logic. Extracted detection patterns and matching functions into fy_detect.h for host-side testing via PlatformIO native environment. 30 tests covering MAC prefix, device name, manufacturer ID, and Raven UUID detection. No firmware behavior changes. --- README.md | 28 ++++ platformio.ini | 6 + src/fy_detect.h | 173 +++++++++++++++++++++++++ src/main.cpp | 160 ++--------------------- test/test_detection/test_detection.cpp | 143 ++++++++++++++++++++ test/test_raven/test_raven.cpp | 105 +++++++++++++++ 6 files changed, 465 insertions(+), 150 deletions(-) create mode 100644 src/fy_detect.h create mode 100644 test/test_detection/test_detection.cpp create mode 100644 test/test_raven/test_raven.cpp diff --git a/README.md b/README.md index 7526737..c56c3b3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,34 @@ Flock-You is part of the OUI-SPY firmware family: --- +## Testing + +Detection logic is extracted into `src/fy_detect.h` and tested on the host machine using PlatformIO's native test framework (Unity). No hardware required. + +### Running Tests + +```bash +pio test -e native # run all tests +pio test -e native -f test_detection # detection logic only +pio test -e native -f test_raven # raven UUID/FW only +``` + +### Test Structure + +``` +test/ + test_detection/ + test_detection.cpp # MAC prefix, device name, manufacturer ID matching + test_raven/ + test_raven.cpp # Raven UUID matching, firmware version estimation +``` + +### Architecture + +`src/fy_detect.h` contains all detection patterns, the `FYDetection` struct, and pure matching functions shared between the firmware and test builds. `main.cpp` includes this header and adds hardware-dependent code (BLE, WiFi, SPIFFS, buzzer). This separation allows the detection logic to be compiled and tested natively without ESP32 toolchains or hardware. + +--- + ## Author **colonelpanichacks** diff --git a/platformio.ini b/platformio.ini index 4bf3b22..b8b1c55 100644 --- a/platformio.ini +++ b/platformio.ini @@ -28,3 +28,9 @@ board_build.filesystem = spiffs board_build.f_cpu = 240000000L board_build.f_flash = 80000000L board_build.flash_mode = qio + +; Native test environment (runs on host, no hardware needed) +[env:native] +platform = native +test_framework = unity +build_flags = -std=c++11 -D_GNU_SOURCE diff --git a/src/fy_detect.h b/src/fy_detect.h new file mode 100644 index 0000000..9f3d886 --- /dev/null +++ b/src/fy_detect.h @@ -0,0 +1,173 @@ +// fy_detect.h — Detection logic and pattern data for Flock-You +// Shared between firmware (main.cpp) and native unit tests. +// All functions are static to avoid linker conflicts. + +#ifndef FY_DETECT_H +#define FY_DETECT_H + +#include +#include + +#ifndef ARDUINO +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include // strncasecmp, strcasecmp on POSIX +#include +#endif + +// ============================================================================ +// DETECTION PATTERNS +// ============================================================================ + +// 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, which produce Flock +// hardware but also ship unrelated consumer/enterprise devices. +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. +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 +// ============================================================================ + +#define MAX_DETECTIONS 200 + +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]; + double gpsLat; + double gpsLon; + float gpsAcc; + bool hasGPS; +}; + +// ============================================================================ +// 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 DETECTION (hardware-independent) +// ============================================================================ + +// Check a list of UUID strings against known Raven service UUIDs. +static bool checkRavenUUIDFromStrings(const char** uuids, int count, char* out_uuid) { + for (int i = 0; i < count; i++) { + for (size_t j = 0; j < sizeof(raven_service_uuids)/sizeof(raven_service_uuids[0]); j++) { + if (strcasecmp(uuids[i], raven_service_uuids[j]) == 0) { + if (out_uuid) strncpy(out_uuid, uuids[i], 40); + return true; + } + } + } + return false; +} + +// Estimate Raven firmware version from which service categories are present. +static const char* estimateRavenFWFromFlags(bool has_new_gps, bool has_old_loc, bool has_power) { + 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 "?"; +} + +#endif // FY_DETECT_H diff --git a/src/main.cpp b/src/main.cpp index ef6832d..ece58db 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ #include #include #include "esp_wifi.h" +#include "fy_detect.h" // ============================================================================ // CONFIGURATION @@ -47,105 +48,10 @@ static int fyBleScanDuration = 2; // seconds per scan static unsigned long fyBleScanInterval = 3000; // ms between scans -// Detection storage -#define MAX_DETECTIONS 200 - // WiFi AP credentials #define FY_AP_SSID "flockyou" #define FY_AP_PASS "flockyou123" -// ============================================================================ -// DETECTION PATTERNS -// ============================================================================ - -// MAC address prefixes (OUIs) - -// Flock Safety — high-confidence OUIs (direct registration or exclusive use) -static const char* flock_mac_prefixes[] = { - // FS Ext Battery devices - "58:8e:81", "cc:cc:cc", "ec:1b:bd", "90:35:ea", "04:0d:84", - "f0:82:c0", "1c:34:f1", "38:5b:44", "94:34:69", "b4:e3:f9", - // Flock WiFi devices - "70:c9:4e", "3c:91:80", "d8:f3:bc", "80:30:49", "14:5a:fc", - "74:4c:a1", "08:3a:88", "9c:2f:9d", "94:08:53", "e4:aa:ea", - // Flock Safety (direct IEEE registration) - "b4:1e:52" -}; - -// Flock Safety contract manufacturers — lower confidence alone. -// These OUIs belong to Liteon Technology and USI (Universal Scientific -// Industrial), which produce Flock hardware but also ship unrelated -// consumer/enterprise devices. MAC match alone may be a false positive. -static const char* flock_mfr_mac_prefixes[] = { - "f4:6a:dd", "f8:a2:d6", "e0:0a:f6", "00:f4:8d", "d0:39:57", - "e8:d0:fc" -}; - -// SoundThinking (formerly ShotSpotter) — acoustic gunshot detection sensors. -// d4:11:d6 is registered to SoundThinking in the IEEE OUI database. -static const char* soundthinking_mac_prefixes[] = { - "d4:11:d6" -}; - -// BLE device name patterns (matched case-insensitive substring) -static const char* device_name_patterns[] = { - "FS Ext Battery", - "Penguin", - "Flock", - "Pigvision" -}; - -// BLE Manufacturer Company IDs -// Source: wgreenberg/flock-you - XUNTONG ID associated with Flock Safety devices -static const uint16_t ble_manufacturer_ids[] = { - 0x09C8 // XUNTONG -}; - -// ============================================================================ -// RAVEN SURVEILLANCE DEVICE UUID PATTERNS -// ============================================================================ - -#define RAVEN_DEVICE_INFO_SERVICE "0000180a-0000-1000-8000-00805f9b34fb" -#define RAVEN_GPS_SERVICE "00003100-0000-1000-8000-00805f9b34fb" -#define RAVEN_POWER_SERVICE "00003200-0000-1000-8000-00805f9b34fb" -#define RAVEN_NETWORK_SERVICE "00003300-0000-1000-8000-00805f9b34fb" -#define RAVEN_UPLOAD_SERVICE "00003400-0000-1000-8000-00805f9b34fb" -#define RAVEN_ERROR_SERVICE "00003500-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_HEALTH_SERVICE "00001809-0000-1000-8000-00805f9b34fb" -#define RAVEN_OLD_LOCATION_SERVICE "00001819-0000-1000-8000-00805f9b34fb" - -static const char* raven_service_uuids[] = { - RAVEN_DEVICE_INFO_SERVICE, - RAVEN_GPS_SERVICE, - RAVEN_POWER_SERVICE, - RAVEN_NETWORK_SERVICE, - RAVEN_UPLOAD_SERVICE, - RAVEN_ERROR_SERVICE, - RAVEN_OLD_HEALTH_SERVICE, - RAVEN_OLD_LOCATION_SERVICE -}; - -// ============================================================================ -// DETECTION STORAGE -// ============================================================================ - -struct FYDetection { - char mac[18]; - char name[48]; - int rssi; - char method[32]; - unsigned long firstSeen; - unsigned long lastSeen; - int count; - bool isRaven; - char ravenFW[16]; - // GPS from phone (wardriving) - double gpsLat; - double gpsLon; - float gpsAcc; - bool hasGPS; -}; - static FYDetection fyDet[MAX_DETECTIONS]; static int fyDetCount = 0; static SemaphoreHandle_t fyMutex = NULL; @@ -269,64 +175,21 @@ static void fyHeartbeat() { } // ============================================================================ -// 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 +// RAVEN UUID DETECTION (NimBLE wrappers — delegates to fy_detect.h) // ============================================================================ 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; - } - } + const char* strs[16]; + std::string bufs[16]; + int n = count < 16 ? count : 16; + for (int i = 0; i < n; i++) { + bufs[i] = device->getServiceUUID(i).toString(); + strs[i] = bufs[i].c_str(); } - return false; + return checkRavenUUIDFromStrings(strs, n, out_uuid); } static const char* estimateRavenFW(NimBLEAdvertisedDevice* device) { @@ -339,10 +202,7 @@ static const char* estimateRavenFW(NimBLEAdvertisedDevice* device) { 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 "?"; + return estimateRavenFWFromFlags(has_new_gps, has_old_loc, has_power); } // ============================================================================ diff --git a/test/test_detection/test_detection.cpp b/test/test_detection/test_detection.cpp new file mode 100644 index 0000000..bcd376b --- /dev/null +++ b/test/test_detection/test_detection.cpp @@ -0,0 +1,143 @@ +// Unit tests for MAC, device name, and manufacturer ID detection logic. + +#include +#include "../../src/fy_detect.h" + +// --- Flock MAC prefix tests --- + +void test_flock_mac_known_prefix(void) { + TEST_ASSERT_TRUE(checkFlockMAC("58:8e:81:aa:bb:cc")); + TEST_ASSERT_TRUE(checkFlockMAC("b4:1e:52:00:11:22")); + TEST_ASSERT_TRUE(checkFlockMAC("70:c9:4e:de:ad:01")); +} + +void test_flock_mac_case_insensitive(void) { + TEST_ASSERT_TRUE(checkFlockMAC("58:8E:81:AA:BB:CC")); + TEST_ASSERT_TRUE(checkFlockMAC("B4:1E:52:00:11:22")); +} + +void test_flock_mac_all_prefixes(void) { + size_t count = sizeof(flock_mac_prefixes) / sizeof(flock_mac_prefixes[0]); + for (size_t i = 0; i < count; i++) { + char mac[18]; + snprintf(mac, sizeof(mac), "%s:00:00:00", flock_mac_prefixes[i]); + TEST_ASSERT_TRUE_MESSAGE(checkFlockMAC(mac), flock_mac_prefixes[i]); + } +} + +void test_flock_mac_no_match(void) { + TEST_ASSERT_FALSE(checkFlockMAC("aa:bb:cc:dd:ee:ff")); + TEST_ASSERT_FALSE(checkFlockMAC("00:00:00:00:00:00")); +} + +// --- Contract manufacturer MAC tests --- + +void test_mfr_mac_known(void) { + TEST_ASSERT_TRUE(checkFlockMfrMAC("f4:6a:dd:11:22:33")); + TEST_ASSERT_TRUE(checkFlockMfrMAC("e8:d0:fc:aa:bb:cc")); +} + +void test_mfr_mac_no_match(void) { + TEST_ASSERT_FALSE(checkFlockMfrMAC("aa:bb:cc:dd:ee:ff")); +} + +void test_mfr_mac_not_in_flock_list(void) { + // Contract mfr MACs should NOT match the high-confidence Flock list + TEST_ASSERT_FALSE(checkFlockMAC("f4:6a:dd:11:22:33")); +} + +// --- SoundThinking MAC tests --- + +void test_soundthinking_mac_known(void) { + TEST_ASSERT_TRUE(checkSoundThinkingMAC("d4:11:d6:aa:bb:cc")); +} + +void test_soundthinking_mac_case_insensitive(void) { + TEST_ASSERT_TRUE(checkSoundThinkingMAC("D4:11:D6:AA:BB:CC")); +} + +void test_soundthinking_mac_no_match(void) { + TEST_ASSERT_FALSE(checkSoundThinkingMAC("aa:bb:cc:dd:ee:ff")); +} + +// --- Device name tests --- + +void test_device_name_exact(void) { + TEST_ASSERT_TRUE(checkDeviceName("FS Ext Battery")); + TEST_ASSERT_TRUE(checkDeviceName("Penguin")); + TEST_ASSERT_TRUE(checkDeviceName("Flock")); + TEST_ASSERT_TRUE(checkDeviceName("Pigvision")); +} + +void test_device_name_substring(void) { + TEST_ASSERT_TRUE(checkDeviceName("My Flock Device")); + TEST_ASSERT_TRUE(checkDeviceName("Pigvision Controller v2")); +} + +void test_device_name_case_insensitive(void) { + TEST_ASSERT_TRUE(checkDeviceName("penguin")); + TEST_ASSERT_TRUE(checkDeviceName("FLOCK")); + TEST_ASSERT_TRUE(checkDeviceName("fs ext battery")); +} + +void test_device_name_no_match(void) { + TEST_ASSERT_FALSE(checkDeviceName("Random BLE Device")); + TEST_ASSERT_FALSE(checkDeviceName("iPhone")); +} + +void test_device_name_null_and_empty(void) { + TEST_ASSERT_FALSE(checkDeviceName(NULL)); + TEST_ASSERT_FALSE(checkDeviceName("")); +} + +// --- Manufacturer ID tests --- + +void test_manufacturer_id_known(void) { + TEST_ASSERT_TRUE(checkManufacturerID(0x09C8)); +} + +void test_manufacturer_id_no_match(void) { + TEST_ASSERT_FALSE(checkManufacturerID(0x0000)); + TEST_ASSERT_FALSE(checkManufacturerID(0xFFFF)); + TEST_ASSERT_FALSE(checkManufacturerID(0x004C)); // Apple +} + +// --- List isolation tests --- + +void test_lists_are_independent(void) { + // SoundThinking MAC should not match Flock or contract mfr lists + TEST_ASSERT_FALSE(checkFlockMAC("d4:11:d6:aa:bb:cc")); + TEST_ASSERT_FALSE(checkFlockMfrMAC("d4:11:d6:aa:bb:cc")); + // Flock MAC should not match SoundThinking + TEST_ASSERT_FALSE(checkSoundThinkingMAC("58:8e:81:aa:bb:cc")); +} + +int main(void) { + UNITY_BEGIN(); + + RUN_TEST(test_flock_mac_known_prefix); + RUN_TEST(test_flock_mac_case_insensitive); + RUN_TEST(test_flock_mac_all_prefixes); + RUN_TEST(test_flock_mac_no_match); + + RUN_TEST(test_mfr_mac_known); + RUN_TEST(test_mfr_mac_no_match); + RUN_TEST(test_mfr_mac_not_in_flock_list); + + RUN_TEST(test_soundthinking_mac_known); + RUN_TEST(test_soundthinking_mac_case_insensitive); + RUN_TEST(test_soundthinking_mac_no_match); + + RUN_TEST(test_device_name_exact); + RUN_TEST(test_device_name_substring); + RUN_TEST(test_device_name_case_insensitive); + RUN_TEST(test_device_name_no_match); + RUN_TEST(test_device_name_null_and_empty); + + RUN_TEST(test_manufacturer_id_known); + RUN_TEST(test_manufacturer_id_no_match); + + RUN_TEST(test_lists_are_independent); + + return UNITY_END(); +} diff --git a/test/test_raven/test_raven.cpp b/test/test_raven/test_raven.cpp new file mode 100644 index 0000000..995b387 --- /dev/null +++ b/test/test_raven/test_raven.cpp @@ -0,0 +1,105 @@ +// Unit tests for Raven UUID matching and firmware version estimation. + +#include +#include "../../src/fy_detect.h" + +// --- Raven UUID matching tests --- + +void test_raven_uuid_known_service(void) { + const char* uuids[] = { RAVEN_DEVICE_INFO_SERVICE }; + char out[41] = {0}; + TEST_ASSERT_TRUE(checkRavenUUIDFromStrings(uuids, 1, out)); + TEST_ASSERT_EQUAL_STRING(RAVEN_DEVICE_INFO_SERVICE, out); +} + +void test_raven_uuid_all_known(void) { + size_t count = sizeof(raven_service_uuids) / sizeof(raven_service_uuids[0]); + for (size_t i = 0; i < count; i++) { + const char* uuids[] = { raven_service_uuids[i] }; + TEST_ASSERT_TRUE_MESSAGE( + checkRavenUUIDFromStrings(uuids, 1, NULL), + raven_service_uuids[i]); + } +} + +void test_raven_uuid_case_insensitive(void) { + const char* uuids[] = { "0000180A-0000-1000-8000-00805F9B34FB" }; + TEST_ASSERT_TRUE(checkRavenUUIDFromStrings(uuids, 1, NULL)); +} + +void test_raven_uuid_no_match(void) { + const char* uuids[] = { "12345678-1234-1234-1234-123456789abc" }; + TEST_ASSERT_FALSE(checkRavenUUIDFromStrings(uuids, 1, NULL)); +} + +void test_raven_uuid_empty_list(void) { + TEST_ASSERT_FALSE(checkRavenUUIDFromStrings(NULL, 0, NULL)); +} + +void test_raven_uuid_mixed_list(void) { + // One unknown, one known — should still match + const char* uuids[] = { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + RAVEN_GPS_SERVICE + }; + char out[41] = {0}; + TEST_ASSERT_TRUE(checkRavenUUIDFromStrings(uuids, 2, out)); + TEST_ASSERT_EQUAL_STRING(RAVEN_GPS_SERVICE, out); +} + +void test_raven_uuid_null_out_buffer(void) { + const char* uuids[] = { RAVEN_POWER_SERVICE }; + TEST_ASSERT_TRUE(checkRavenUUIDFromStrings(uuids, 1, NULL)); +} + +// --- Firmware version estimation tests --- + +void test_fw_v11x(void) { + // Old location service present, no new GPS + TEST_ASSERT_EQUAL_STRING("1.1.x", + estimateRavenFWFromFlags(false, true, false)); +} + +void test_fw_v12x(void) { + // New GPS present, no power service + TEST_ASSERT_EQUAL_STRING("1.2.x", + estimateRavenFWFromFlags(true, false, false)); +} + +void test_fw_v13x(void) { + // New GPS and power both present + TEST_ASSERT_EQUAL_STRING("1.3.x", + estimateRavenFWFromFlags(true, false, true)); +} + +void test_fw_unknown(void) { + // No relevant services + TEST_ASSERT_EQUAL_STRING("?", + estimateRavenFWFromFlags(false, false, false)); +} + +void test_fw_v13x_all_flags(void) { + // All flags true — new GPS + power wins + TEST_ASSERT_EQUAL_STRING("1.3.x", + estimateRavenFWFromFlags(true, true, true)); +} + +int main(void) { + UNITY_BEGIN(); + + RUN_TEST(test_raven_uuid_known_service); + RUN_TEST(test_raven_uuid_all_known); + RUN_TEST(test_raven_uuid_case_insensitive); + RUN_TEST(test_raven_uuid_no_match); + RUN_TEST(test_raven_uuid_empty_list); + RUN_TEST(test_raven_uuid_mixed_list); + RUN_TEST(test_raven_uuid_null_out_buffer); + + RUN_TEST(test_fw_v11x); + RUN_TEST(test_fw_v12x); + RUN_TEST(test_fw_v13x); + RUN_TEST(test_fw_unknown); + RUN_TEST(test_fw_v13x_all_flags); + + return UNITY_END(); +} From b09ca5b8c724aff285fa9e643ab21dac0754df35 Mon Sep 17 00:00:00 2001 From: agstewart Date: Sat, 7 Mar 2026 19:16:01 -0600 Subject: [PATCH 8/9] Add test injection endpoint, fix blocking buzzer crash. Compile-guarded /api/test/inject endpoint for simulating detections. Fix fyDetectBeep() blocking BLE/HTTP tasks by deferring to loop() via fyDetectBeepPending flag. Rename test dirs for clarity. --- README.md | 31 +++++-- platformio.ini | 8 ++ src/main.cpp | 93 ++++++++++++++++++- .../test_ble_matching.cpp} | 0 .../test_uuid_matching.cpp} | 0 5 files changed, 125 insertions(+), 7 deletions(-) rename test/{test_detection/test_detection.cpp => test_ble_matching/test_ble_matching.cpp} (100%) rename test/{test_raven/test_raven.cpp => test_uuid_matching/test_uuid_matching.cpp} (100%) diff --git a/README.md b/README.md index c56c3b3..f065aa9 100644 --- a/README.md +++ b/README.md @@ -160,20 +160,39 @@ Detection logic is extracted into `src/fy_detect.h` and tested on the host machi ```bash pio test -e native # run all tests -pio test -e native -f test_detection # detection logic only -pio test -e native -f test_raven # raven UUID/FW only +pio test -e native -f test_ble_matching # MAC, name, manufacturer ID +pio test -e native -f test_uuid_matching # Raven UUID/FW estimation ``` ### Test Structure ``` test/ - test_detection/ - test_detection.cpp # MAC prefix, device name, manufacturer ID matching - test_raven/ - test_raven.cpp # Raven UUID matching, firmware version estimation + test_ble_matching/ + test_ble_matching.cpp # MAC prefix, device name, manufacturer ID matching + test_uuid_matching/ + test_uuid_matching.cpp # Raven UUID matching, firmware version estimation ``` +### Hardware Testing (Test Firmware) + +A separate test build includes an endpoint that injects simulated detections through the full pipeline — storage, dashboard, buzzer, serial output, BLE notification, and SPIFFS persistence. + +```bash +pio run -e xiao_esp32s3_test -t upload # flash test firmware +``` + +Then trigger simulated detections: + +```bash +curl http://192.168.4.1/api/test/inject?type=flock # Flock camera +curl http://192.168.4.1/api/test/inject?type=raven # Raven gunshot detector +curl http://192.168.4.1/api/test/inject?type=soundthinking # SoundThinking sensor +curl http://192.168.4.1/api/test/inject?type=mfr # low-confidence mfr match (no buzzer) +``` + +The test endpoint is compiled out of production firmware (`pio run -e xiao_esp32s3`) and does not exist in the binary. + ### Architecture `src/fy_detect.h` contains all detection patterns, the `FYDetection` struct, and pure matching functions shared between the firmware and test builds. `main.cpp` includes this header and adds hardware-dependent code (BLE, WiFi, SPIFFS, buzzer). This separation allows the detection logic to be compiled and tested natively without ESP32 toolchains or hardware. diff --git a/platformio.ini b/platformio.ini index b8b1c55..fab2aa9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,14 @@ board_build.f_cpu = 240000000L board_build.f_flash = 80000000L board_build.flash_mode = qio +; Test firmware — includes /api/test/inject endpoint for hardware verification. +; Build with: pio run -e xiao_esp32s3_test +[env:xiao_esp32s3_test] +extends = env:xiao_esp32s3 +build_flags = + ${env:xiao_esp32s3.build_flags} + -DFY_ENABLE_TEST_API + ; Native test environment (runs on host, no hardware needed) [env:native] platform = native diff --git a/src/main.cpp b/src/main.cpp index ece58db..dfe0e2a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -86,6 +86,10 @@ static unsigned long fyLastSerialHeartbeat = 0; // loop() applies the WiFi/scan changes in the Arduino task context. static volatile bool fyCompanionChangePending = false; +// Deferred buzzer — detection alert must not block BLE/HTTP task contexts. +// Callers set this flag; loop() plays the sound in the Arduino task. +static volatile bool fyDetectBeepPending = false; + // Phone GPS state (updated via browser Geolocation API -> /api/gps) static double fyGPSLat = 0; static double fyGPSLon = 0; @@ -446,7 +450,7 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks { if (!fyTriggered && highConfidence) { fyTriggered = true; - fyDetectBeep(); + fyDetectBeepPending = true; } if (highConfidence) { fyDeviceInRange = true; @@ -935,6 +939,87 @@ static void fySetupServer() { printf("[FLOCK-YOU] All detections cleared (session saved)\n"); }); +#ifdef FY_ENABLE_TEST_API + // API: Inject a simulated detection for testing the full pipeline. + // Only compiled when FY_ENABLE_TEST_API is defined in build flags. + // Usage: /api/test/inject?type=flock|raven|soundthinking|mfr + fyServer.on("/api/test/inject", HTTP_GET, [](AsyncWebServerRequest *r) { + const char* type = "flock"; + if (r->hasParam("type")) type = r->getParam("type")->value().c_str(); + + const char* mac; + const char* name; + int rssi; + const char* method; + bool isRaven = false; + const char* ravenFW = ""; + bool highConfidence = true; + + if (strcasecmp(type, "raven") == 0) { + mac = "DE:AD:00:RA:VE:01"; + name = "Raven Test Unit"; + rssi = -55; + method = "raven_uuid"; + isRaven = true; + ravenFW = "1.3.x"; + } else if (strcasecmp(type, "soundthinking") == 0) { + mac = "D4:11:D6:TE:ST:01"; + name = "SoundThinking Test"; + rssi = -60; + method = "mac_prefix_soundthinking"; + } else if (strcasecmp(type, "mfr") == 0) { + mac = "F4:6A:DD:TE:ST:01"; + name = "Mfr Test Device"; + rssi = -75; + method = "mac_prefix_mfr"; + highConfidence = false; + } else { + mac = "DE:AD:BE:EF:00:01"; + name = "FS Ext Battery Test"; + rssi = -65; + method = "mac_prefix"; + } + + int idx = fyAddDetection(mac, name, rssi, method, isRaven, ravenFW); + + // Serial + BLE JSON output (mirrors real detection path) + char gpsBuf[80] = ""; + if (fyGPSIsFresh()) { + snprintf(gpsBuf, sizeof(gpsBuf), + ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", + fyGPSLat, fyGPSLon, fyGPSAcc); + } + char jsonBuf[512]; + int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), + "{\"event\":\"detection\",\"detection_method\":\"%s\"," + "\"protocol\":\"test_inject\",\"mac_address\":\"%s\"," + "\"device_name\":\"%s\",\"rssi\":%d," + "\"is_raven\":%s,\"raven_fw\":\"%s\"%s}", + method, mac, name, rssi, + isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); + printf("[FLOCK-YOU] TEST INJECT: %s\n", jsonBuf); + if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { + jsonBuf[jsonLen] = '\n'; + fySendBLE(jsonBuf, jsonLen + 1); + } + + // Buzzer and heartbeat tracking (same as real detection) + if (!fyTriggered && highConfidence) { + fyTriggered = true; + fyDetectBeepPending = true; + } + if (highConfidence) { + fyDeviceInRange = true; + fyLastDetTime = millis(); + fyLastHB = millis(); + } + + r->send(200, "application/json", jsonBuf); + printf("[FLOCK-YOU] Test detection injected: %s [%s] idx:%d\n", + mac, method, idx); + }); +#endif // FY_ENABLE_TEST_API + fyServer.begin(); printf("[FLOCK-YOU] Web server started on port 80\n"); } @@ -1042,6 +1127,12 @@ void loop() { fyOnCompanionChange(); } + // Play deferred detection alert (set by BLE callback or test endpoint) + if (fyDetectBeepPending) { + fyDetectBeepPending = false; + fyDetectBeep(); + } + // BLE scanning cycle if (millis() - fyLastBleScan >= fyBleScanInterval && !fyBLEScan->isScanning()) { fyBLEScan->start(fyBleScanDuration, false); diff --git a/test/test_detection/test_detection.cpp b/test/test_ble_matching/test_ble_matching.cpp similarity index 100% rename from test/test_detection/test_detection.cpp rename to test/test_ble_matching/test_ble_matching.cpp diff --git a/test/test_raven/test_raven.cpp b/test/test_uuid_matching/test_uuid_matching.cpp similarity index 100% rename from test/test_raven/test_raven.cpp rename to test/test_uuid_matching/test_uuid_matching.cpp From fa784bbbdcd09d248776a363e041b2b5f760724e Mon Sep 17 00:00:00 2001 From: agstewart Date: Sun, 8 Mar 2026 01:49:20 -0600 Subject: [PATCH 9/9] Fix garbage HTTP responses, add crash reproduction test. Fix native env breaking pio run with default_envs and build_src_filter. --- platformio.ini | 4 + src/main.cpp | 6 +- test/test_buzzer_crash.py | 185 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 test/test_buzzer_crash.py diff --git a/platformio.ini b/platformio.ini index fab2aa9..48b8ced 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,6 @@ +[platformio] +default_envs = xiao_esp32s3 + [env:xiao_esp32s3] platform = espressif32@^6.3.0 board = seeed_xiao_esp32s3 @@ -42,3 +45,4 @@ build_flags = platform = native test_framework = unity build_flags = -std=c++11 -D_GNU_SOURCE +build_src_filter = -<*> diff --git a/src/main.cpp b/src/main.cpp index dfe0e2a..897551d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -998,6 +998,10 @@ static void fySetupServer() { method, mac, name, rssi, isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); printf("[FLOCK-YOU] TEST INJECT: %s\n", jsonBuf); + + // Send HTTP response before BLE framing overwrites the null terminator + r->send(200, "application/json", jsonBuf); + if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { jsonBuf[jsonLen] = '\n'; fySendBLE(jsonBuf, jsonLen + 1); @@ -1013,8 +1017,6 @@ static void fySetupServer() { fyLastDetTime = millis(); fyLastHB = millis(); } - - r->send(200, "application/json", jsonBuf); printf("[FLOCK-YOU] Test detection injected: %s [%s] idx:%d\n", mac, method, idx); }); diff --git a/test/test_buzzer_crash.py b/test/test_buzzer_crash.py new file mode 100644 index 0000000..66540fe --- /dev/null +++ b/test/test_buzzer_crash.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Reproduce and validate the blocking buzzer crash bug. + +Sends rapid test detections to a Flock-You device running test firmware +(xiao_esp32s3_test). Detects crash by monitoring for connection failure +followed by detection count reset. + +Usage: + python3 test/test_buzzer_crash.py [--host 192.168.4.1] [--fixed] + + --fixed Run against firmware with the deferred buzzer fix applied. + Expects NO crash after sustained injections. +""" + +import argparse +import sys +import time +import urllib.request +import urllib.error +import json + + +DEVICE_TYPES = ["flock", "raven", "soundthinking", "mfr"] + + +def fetch(url, timeout=5): + """Fetch URL, return (response_text, True) or (error_msg, False).""" + try: + req = urllib.request.urlopen(url, timeout=timeout) + return req.read().decode("utf-8", errors="replace"), True + except urllib.error.URLError as e: + return str(e), False + except Exception as e: + return str(e), False + + +def get_detection_count(host): + """Return current detection count, or -1 on connection failure.""" + body, ok = fetch(f"http://{host}/api/detections") + if not ok: + return -1 + try: + return len(json.loads(body)) + except json.JSONDecodeError: + return -1 + + +def wait_for_device(host, timeout=30): + """Wait for the device to come back online. Returns True if it did.""" + print(f" Waiting for device to come back (up to {timeout}s)...", end="", flush=True) + start = time.time() + while time.time() - start < timeout: + count = get_detection_count(host) + if count >= 0: + print(f" online (detections: {count})") + return True + time.sleep(1) + print(".", end="", flush=True) + print(" timeout") + return False + + +def clear_detections(host): + """Clear all detections on the device.""" + _, ok = fetch(f"http://{host}/api/clear") + return ok + + +def inject_detection(host, device_type="flock"): + """Inject a test detection. Returns (response, success).""" + return fetch(f"http://{host}/api/test/inject?type={device_type}") + + +def run_crash_test(host, expect_crash): + """ + Send rapid detections and check for crash/reboot. + Returns True if the test result matches expectations. + """ + mode = "UNFIXED (expecting crash)" if expect_crash else "FIXED (expecting stability)" + print(f"\n{'='*60}") + print(f" Buzzer crash test — {mode}") + print(f" Target: {host}") + print(f"{'='*60}\n") + + # Verify device is reachable + count = get_detection_count(host) + if count < 0: + print("ERROR: Device not reachable. Is it powered on and running test firmware?") + return False + + # Clear any existing detections + print("Clearing detections...") + clear_detections(host) + time.sleep(1) + + # Wait for fyTriggered to reset (needs 30s without detections) + # But if we just cleared, we need the first injection to trigger the buzzer + print("Waiting 5s for trigger state to settle...") + time.sleep(5) + + # Send rapid detections + max_injections = 30 + crash_detected = False + successful = 0 + failed = 0 + garbage_seen = False + + print(f"Injecting up to {max_injections} detections (interval: 3s)...\n") + + for i in range(max_injections): + device_type = DEVICE_TYPES[i % len(DEVICE_TYPES)] + body, ok = inject_detection(host, device_type) + + if not ok: + print(f" #{i+1:2d} [{device_type:14s}] FAILED — connection lost") + failed += 1 + crash_detected = True + break + + # Check for garbage bytes in response (corruption precursor) + try: + json.loads(body) + status = "OK" + except json.JSONDecodeError: + status = "GARBAGE RESPONSE" + garbage_seen = True + + print(f" #{i+1:2d} [{device_type:14s}] {status}") + successful += 1 + time.sleep(3) + + print(f"\nInjections: {successful} succeeded, {failed} failed") + if garbage_seen: + print("WARNING: Garbage bytes detected in responses (memory corruption)") + + # Check if device crashed + if crash_detected: + print("\nDevice connection lost — likely crash/reboot.") + came_back = wait_for_device(host) + if came_back: + post_count = get_detection_count(host) + if post_count == 0: + print("CONFIRMED: Device rebooted — detection count reset to 0.") + print("BUG REPRODUCED: Blocking delay() in callback caused watchdog reset.\n") + elif post_count > 0: + print(f"Device back with {post_count} detections (SPIFFS may have saved partial data).\n") + else: + print("Device did not come back within timeout.\n") + else: + # No crash — verify detections are intact + post_count = get_detection_count(host) + print(f"\nNo crash detected. Final detection count: {post_count}") + + # Evaluate result + if expect_crash: + if crash_detected: + print("PASS: Crash occurred as expected on unfixed firmware.") + return True + else: + print("INCONCLUSIVE: No crash observed. Device may need more sustained load,") + print("or the bug may require real BLE callback context to trigger.") + return False + else: + if crash_detected: + print("FAIL: Crash occurred on supposedly fixed firmware!") + return False + else: + print("PASS: No crash — deferred buzzer fix is working.") + return True + + +def main(): + parser = argparse.ArgumentParser(description="Test for blocking buzzer crash bug") + parser.add_argument("--host", default="192.168.4.1", help="Device IP (default: 192.168.4.1)") + parser.add_argument("--fixed", action="store_true", help="Expect no crash (testing fixed firmware)") + args = parser.parse_args() + + expect_crash = not args.fixed + passed = run_crash_test(args.host, expect_crash) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main()