diff --git a/README.md b/README.md index 7526737..f065aa9 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,53 @@ 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_ble_matching # MAC, name, manufacturer ID +pio test -e native -f test_uuid_matching # Raven UUID/FW estimation +``` + +### Test Structure + +``` +test/ + 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. + +--- + ## Author **colonelpanichacks** diff --git a/platformio.ini b/platformio.ini index 4bf3b22..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 @@ -28,3 +31,18 @@ board_build.filesystem = spiffs 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 +test_framework = unity +build_flags = -std=c++11 -D_GNU_SOURCE +build_src_filter = -<*> 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 cb264bc..897551d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ #include #include #include "esp_wifi.h" +#include "fy_detect.h" // ============================================================================ // CONFIGURATION @@ -43,90 +44,14 @@ #define DETECT_BEEP_DURATION 150 #define HEARTBEAT_DURATION 100 -// BLE scanning -#define BLE_SCAN_DURATION 2 // seconds per scan -#define BLE_SCAN_INTERVAL 3000 // ms between scans - -// Detection storage -#define MAX_DETECTIONS 200 +// BLE scanning (mutable — companion mode increases scan duty cycle) +static int fyBleScanDuration = 2; // seconds per scan +static unsigned long fyBleScanInterval = 3000; // ms between scans // WiFi AP credentials #define FY_AP_SSID "flockyou" #define FY_AP_PASS "flockyou123" -// ============================================================================ -// DETECTION PATTERNS -// ============================================================================ - -// Known Flock Safety MAC address prefixes (OUIs) -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 - "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" -}; - -// 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[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; @@ -144,6 +69,27 @@ static unsigned long fyLastHB = 0; static NimBLEScan* fyBLEScan = NULL; static AsyncWebServer fyServer(80); +// BLE GATT server (DeFlock app connectivity) +#define FY_SERVICE_UUID "a1b2c3d4-e5f6-7890-abcd-ef0123456789" +#define FY_TX_CHAR_UUID "a1b2c3d4-e5f6-7890-abcd-ef01234567aa" +static NimBLEServer* fyBLEServer = NULL; +static NimBLECharacteristic* fyTxChar = NULL; +static volatile bool fyBLEClientConnected = false; +static volatile uint16_t fyNegotiatedMTU = 23; + +// Serial host detection (USB heartbeat from DeFlock desktop) +static volatile bool fySerialHostConnected = false; +static unsigned long fyLastSerialHeartbeat = 0; +#define FY_SERIAL_TIMEOUT_MS 5000 + +// Deferred companion mode switch — BLE callbacks set this flag, +// loop() applies the WiFi/scan changes in the Arduino task context. +static volatile bool fyCompanionChangePending = false; + +// 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; @@ -233,52 +179,21 @@ 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; - } - 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) { @@ -291,10 +206,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); } // ============================================================================ @@ -368,6 +280,69 @@ static int fyAddDetection(const char* mac, const char* name, int rssi, return -1; } +// ============================================================================ +// BLE GATT SERVER (DeFlock companion connectivity) +// ============================================================================ + +class FYServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { + fyBLEClientConnected = true; + fyCompanionChangePending = true; + } + void onDisconnect(NimBLEServer* pServer, ble_gap_conn_desc* desc) override { + fyBLEClientConnected = false; + fyNegotiatedMTU = 23; + NimBLEDevice::startAdvertising(); + fyCompanionChangePending = true; + } + void onMTUChange(uint16_t mtu, ble_gap_conn_desc* desc) override { + fyNegotiatedMTU = mtu; + printf("[FLOCK-YOU] MTU negotiated: %u\n", mtu); + } +}; + +static void fySendBLE(const char* data, size_t len) { + if (!fyBLEClientConnected || !fyTxChar) return; + uint16_t chunkSize = fyNegotiatedMTU - 3; + if (chunkSize < 1) chunkSize = 1; + if (len <= chunkSize) { + fyTxChar->setValue((const uint8_t*)data, len); + fyTxChar->notify(); + } else { + size_t offset = 0; + while (offset < len) { + size_t remaining = len - offset; + size_t send = remaining < chunkSize ? remaining : chunkSize; + fyTxChar->setValue((const uint8_t*)(data + offset), send); + fyTxChar->notify(); + offset += send; + } + } +} + +// ============================================================================ +// COMPANION MODE (WiFi AP vs BLE/serial) +// ============================================================================ + +static void fyOnCompanionChange() { + if (fyBLEClientConnected || fySerialHostConnected) { + // Companion mode — disable WiFi AP, boost BLE scanning + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_OFF); + fyBleScanDuration = 3; + printf("[FLOCK-YOU] Companion mode: WiFi AP OFF, scan duration %ds\n", + fyBleScanDuration); + } else { + // Standalone mode — re-enable WiFi AP and web dashboard + WiFi.mode(WIFI_AP); + delay(100); + WiFi.softAP(FY_AP_SSID, FY_AP_PASS); + fyBleScanDuration = 2; + printf("[FLOCK-YOU] Standalone mode: WiFi AP ON (%s), scan duration %ds\n", + FY_AP_SSID, fyBleScanDuration); + } +} + // ============================================================================ // BLE SCANNING // ============================================================================ @@ -377,34 +352,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); @@ -420,7 +406,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)) { @@ -440,33 +426,37 @@ 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); } - 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); + char jsonBuf[512]; + int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), + "{\"event\":\"detection\",\"detection_method\":\"%s\"," + "\"protocol\":\"bluetooth_le\",\"mac_address\":\"%s\"," + "\"device_name\":\"%s\",\"rssi\":%d," + "\"is_raven\":%s,\"raven_fw\":\"%s\"%s}", + method, addrStr.c_str(), name.c_str(), rssi, + isRaven ? "true" : "false", isRaven ? ravenFW : "", gpsBuf); + printf("%s\n", jsonBuf); + // Append newline for BLE framing and send + if (jsonLen > 0 && jsonLen < (int)sizeof(jsonBuf) - 1) { + jsonBuf[jsonLen] = '\n'; + fySendBLE(jsonBuf, jsonLen + 1); } - if (!fyTriggered) { + if (!fyTriggered && highConfidence) { fyTriggered = true; - fyDetectBeep(); + fyDetectBeepPending = true; + } + if (highConfidence) { + fyDeviceInRange = true; + fyLastDetTime = millis(); + fyLastHB = millis(); } - fyDeviceInRange = true; - fyLastDetTime = millis(); - fyLastHB = millis(); } } }; @@ -696,7 +686,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('')+'
'; @@ -782,9 +774,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\"", mac_prefixes[i]); + resp->printf("\"%s\"", flock_mac_prefixes[i]); + } + resp->print("],\"macs_mfr\":["); + for (size_t i = 0; i < sizeof(flock_mfr_mac_prefixes)/sizeof(flock_mfr_mac_prefixes[0]); i++) { + if (i > 0) resp->print(","); + resp->printf("\"%s\"", flock_mfr_mac_prefixes[i]); + } + resp->print("],\"macs_soundthinking\":["); + for (size_t i = 0; i < sizeof(soundthinking_mac_prefixes)/sizeof(soundthinking_mac_prefixes[0]); i++) { + if (i > 0) resp->print(","); + resp->printf("\"%s\"", soundthinking_mac_prefixes[i]); } resp->print("],\"names\":["); for (size_t i = 0; i < sizeof(device_name_patterns)/sizeof(device_name_patterns[0]); i++) { @@ -937,6 +939,89 @@ 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); + + // 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); + } + + // Buzzer and heartbeat tracking (same as real detection) + if (!fyTriggered && highConfidence) { + fyTriggered = true; + fyDetectBeepPending = true; + } + if (highConfidence) { + fyDeviceInRange = true; + fyLastDetTime = millis(); + fyLastHB = millis(); + } + 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"); } @@ -972,8 +1057,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); @@ -981,10 +1069,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(); @@ -1000,17 +1105,43 @@ 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; + fyCompanionChangePending = true; + } + } else if (fySerialHostConnected && + millis() - fyLastSerialHeartbeat >= FY_SERIAL_TIMEOUT_MS) { + fySerialHostConnected = false; + fyCompanionChangePending = true; + } + + // Apply deferred companion mode switch (from BLE callbacks or serial detection) + if (fyCompanionChangePending) { + fyCompanionChangePending = false; + fyOnCompanionChange(); + } + + // Play deferred detection alert (set by BLE callback or test endpoint) + if (fyDetectBeepPending) { + fyDetectBeepPending = false; + fyDetectBeep(); + } + // 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(); } diff --git a/test/test_ble_matching/test_ble_matching.cpp b/test/test_ble_matching/test_ble_matching.cpp new file mode 100644 index 0000000..bcd376b --- /dev/null +++ b/test/test_ble_matching/test_ble_matching.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_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() diff --git a/test/test_uuid_matching/test_uuid_matching.cpp b/test/test_uuid_matching/test_uuid_matching.cpp new file mode 100644 index 0000000..995b387 --- /dev/null +++ b/test/test_uuid_matching/test_uuid_matching.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(); +}