From 23635be125065b05443c0d0611047daaa9467bb0 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 20:38:09 -0700 Subject: [PATCH 1/3] 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 --- 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 47739675c7ef8b68a64200b184cae48782bf509b Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:03:19 -0700 Subject: [PATCH 2/3] 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. --- 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 a6cc6c74aedfedda7f3e81d728cb4b8a9499c6c2 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Feb 2026 21:46:51 -0700 Subject: [PATCH 3/3] 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. --- 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) {