Add BLE GATT server, serial host detection, and companion mode

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
This commit is contained in:
Doug Borg
2026-02-07 20:38:09 -07:00
parent 32d180dc19
commit 23635be125
+147 -20
View File
@@ -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();
}