flockyou: bulletproof GPS tagging + atomic session persistence

Fix GPS not tagging all detections:
- Add dedicated fyGPSMutex guarding fyGPSLat/Lon/Acc/Valid/LastUpdate
- fyGPSSnapshot() + fyGPSUpdate() replace direct global reads/writes,
  eliminating the race between BLE callback (reader) and /api/gps
  HTTP handler (writer)
- fyBackfillGPS() runs every 2s in loop(), stamping any detection that
  was recorded before the first phone GPS fix became available
- BLE callback JSON emitter now uses snapshot instead of raw globals

Make data saving/transfer bulletproof:
- New envelope format: header line {v,count,bytes,crc32} + payload array
- CRC32 verification catches any truncation or corruption on read
- Atomic write: compute CRC pass 1, write tmp + verify, then rename to
  final (SPIFFS.rename with copy+delete fallback)
- Boot-time recovery: if session.json is missing/corrupt, recover from
  session.tmp; legacy raw-array files still load for back-compat
- Save cadence tightened: within 5s of first detection, after any new
  unique detection (3s throttle), and periodic safety net every 15s
- Export mutex timeouts raised 200->500ms to prevent empty CSV/JSON
  exports under heavy BLE traffic
- /api/history and /api/history/kml strip envelope header before
  returning body so downstream tools keep working unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colonel Panic
2026-04-17 06:46:27 -04:00
parent e307b821e6
commit 3dc74c2b9d

View File

@@ -148,7 +148,8 @@ struct FYDetection {
static FYDetection fyDet[MAX_DETECTIONS]; static FYDetection fyDet[MAX_DETECTIONS];
static int fyDetCount = 0; static int fyDetCount = 0;
static SemaphoreHandle_t fyMutex = NULL; static SemaphoreHandle_t fyMutex = NULL; // guards fyDet[] + fyDetCount
static SemaphoreHandle_t fyGPSMutex = NULL; // guards fyGPS* globals
// ============================================================================ // ============================================================================
// GLOBALS // GLOBALS
@@ -346,20 +347,81 @@ static const char* estimateRavenFW(NimBLEAdvertisedDevice* device) {
} }
// ============================================================================ // ============================================================================
// GPS HELPERS // GPS HELPERS (mutex-protected snapshot pattern)
// ============================================================================ // ============================================================================
// Fast advisory check — safe lock-free (for UI/stats only, don't trust for writes)
static bool fyGPSIsFresh() { static bool fyGPSIsFresh() {
return fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS); return fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS);
} }
static void fyAttachGPS(FYDetection& d) { // Atomic snapshot: returns true and fills out-params if GPS is fresh & valid.
if (fyGPSIsFresh()) { // Safe to call from BLE callback context — never races with producer.
d.hasGPS = true; static bool fyGPSSnapshot(double& lat, double& lon, float& acc) {
d.gpsLat = fyGPSLat; if (!fyGPSMutex) return false;
d.gpsLon = fyGPSLon; if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return false;
d.gpsAcc = fyGPSAcc; bool fresh = fyGPSValid && (millis() - fyGPSLastUpdate < GPS_STALE_MS);
if (fresh) { lat = fyGPSLat; lon = fyGPSLon; acc = fyGPSAcc; }
xSemaphoreGive(fyGPSMutex);
return fresh;
} }
// Atomic GPS publish (from phone via /api/gps or from companion app)
static void fyGPSUpdate(double lat, double lon, float acc) {
if (!fyGPSMutex) return;
if (xSemaphoreTake(fyGPSMutex, pdMS_TO_TICKS(20)) != pdTRUE) return;
fyGPSLat = lat;
fyGPSLon = lon;
fyGPSAcc = acc;
fyGPSValid = true;
fyGPSLastUpdate = millis();
xSemaphoreGive(fyGPSMutex);
}
// Stamp a detection with current GPS if available (used at first-sight and re-sight)
static void fyAttachGPS(FYDetection& d) {
double lat, lon;
float acc;
if (fyGPSSnapshot(lat, lon, acc)) {
d.hasGPS = true;
d.gpsLat = lat;
d.gpsLon = lon;
d.gpsAcc = acc;
}
}
// Periodic: back-fill GPS on detections recorded before a fix was available.
// Runs in main loop — MUST NOT be called from BLE callback (takes fyMutex).
static void fyBackfillGPS() {
double lat, lon;
float acc;
if (!fyGPSSnapshot(lat, lon, acc)) return;
if (!fyMutex || xSemaphoreTake(fyMutex, pdMS_TO_TICKS(50)) != pdTRUE) return;
int filled = 0;
for (int i = 0; i < fyDetCount; i++) {
if (!fyDet[i].hasGPS) {
fyDet[i].hasGPS = true;
fyDet[i].gpsLat = lat;
fyDet[i].gpsLon = lon;
fyDet[i].gpsAcc = acc;
filled++;
}
}
xSemaphoreGive(fyMutex);
if (filled) printf("[FLOCK-YOU] GPS backfilled %d detection(s)\n", filled);
}
// ============================================================================
// CRC32 (IEEE 802.3) — for session file integrity
// ============================================================================
static uint32_t fyCRC32Update(uint32_t crc, const uint8_t* data, size_t len) {
crc = ~crc;
while (len--) {
crc ^= *data++;
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320UL & -(crc & 1));
}
return ~crc;
} }
// ============================================================================ // ============================================================================
@@ -563,11 +625,15 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks {
idx >= 0 ? fyDet[idx].count : 0); idx >= 0 ? fyDet[idx].count : 0);
// JSON output — build into buffer for serial + BLE // JSON output — build into buffer for serial + BLE
// Use atomic snapshot to avoid races with /api/gps writer
char gpsBuf[80] = ""; char gpsBuf[80] = "";
if (fyGPSIsFresh()) { {
double sLat, sLon; float sAcc;
if (fyGPSSnapshot(sLat, sLon, sAcc)) {
snprintf(gpsBuf, sizeof(gpsBuf), snprintf(gpsBuf, sizeof(gpsBuf),
",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}", ",\"gps\":{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy\":%.1f}",
fyGPSLat, fyGPSLon, fyGPSAcc); sLat, sLon, sAcc);
}
} }
char jsonBuf[512]; char jsonBuf[512];
int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf), int jsonLen = snprintf(jsonBuf, sizeof(jsonBuf),
@@ -603,7 +669,7 @@ class FYBLECallbacks : public NimBLEAdvertisedDeviceCallbacks {
static void writeDetectionsJSON(AsyncResponseStream *resp) { static void writeDetectionsJSON(AsyncResponseStream *resp) {
resp->print("["); resp->print("[");
if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) {
for (int i = 0; i < fyDetCount; i++) { for (int i = 0; i < fyDetCount; i++) {
if (i > 0) resp->print(","); if (i > 0) resp->print(",");
resp->printf( resp->printf(
@@ -626,73 +692,248 @@ static void writeDetectionsJSON(AsyncResponseStream *resp) {
} }
// ============================================================================ // ============================================================================
// SESSION PERSISTENCE (SPIFFS) // SESSION PERSISTENCE (SPIFFS) — bulletproof envelope format
// ============================================================================ // ============================================================================
//
// Wire format on disk:
// Line 1: {"v":1,"count":N,"bytes":B,"crc":"0xXXXXXXXX"}\n
// Line 2+: [{"mac":...},{"mac":...},...] (exactly B bytes, CRC32 == X)
//
// Atomic write procedure:
// 1. Compute size+CRC over the detections payload (pass 1, under fyMutex)
// 2. Write envelope header + payload to /session.tmp (pass 2, under same lock)
// 3. Remove /session.json
// 4. Rename /session.tmp → /session.json (with copy+delete fallback)
//
// Recovery: if /session.json is missing or CRC-invalid, fall back to /session.tmp.
static void fySaveSession() { #define FY_SESSION_TMP "/session.tmp"
if (!fySpiffsReady || !fyMutex) return;
if (xSemaphoreTake(fyMutex, pdMS_TO_TICKS(300)) != pdTRUE) return;
File f = SPIFFS.open(FY_SESSION_FILE, "w"); // Serialize a single detection to `dst`. Returns bytes written (0 on overflow).
if (!f) { xSemaphoreGive(fyMutex); return; } static size_t fySerializeDet(const FYDetection& d, char* dst, size_t cap) {
int n;
f.print("["); if (d.hasGPS) {
for (int i = 0; i < fyDetCount; i++) { n = snprintf(dst, cap,
if (i > 0) f.print(","); "{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\","
FYDetection& d = fyDet[i];
f.printf("{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\","
"\"first\":%lu,\"last\":%lu,\"count\":%d," "\"first\":%lu,\"last\":%lu,\"count\":%d,"
"\"raven\":%s,\"fw\":\"%s\"", "\"raven\":%s,\"fw\":\"%s\","
"\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}}",
d.mac, d.name, d.rssi, d.method,
d.firstSeen, d.lastSeen, d.count,
d.isRaven ? "true" : "false", d.ravenFW,
d.gpsLat, d.gpsLon, d.gpsAcc);
} else {
n = snprintf(dst, cap,
"{\"mac\":\"%s\",\"name\":\"%s\",\"rssi\":%d,\"method\":\"%s\","
"\"first\":%lu,\"last\":%lu,\"count\":%d,"
"\"raven\":%s,\"fw\":\"%s\"}",
d.mac, d.name, d.rssi, d.method, d.mac, d.name, d.rssi, d.method,
d.firstSeen, d.lastSeen, d.count, d.firstSeen, d.lastSeen, d.count,
d.isRaven ? "true" : "false", d.ravenFW); d.isRaven ? "true" : "false", d.ravenFW);
if (d.hasGPS) {
f.printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", d.gpsLat, d.gpsLon, d.gpsAcc);
} }
f.print("}"); return (n > 0 && (size_t)n < cap) ? (size_t)n : 0;
}
// Pass 1: compute exact payload size + CRC32 without allocating.
// Caller MUST hold fyMutex.
static uint32_t fyComputePayloadCRC(size_t& outBytes) {
char line[512];
uint32_t crc = 0;
outBytes = 0;
crc = fyCRC32Update(crc, (const uint8_t*)"[", 1); outBytes += 1;
for (int i = 0; i < fyDetCount; i++) {
if (i > 0) { crc = fyCRC32Update(crc, (const uint8_t*)",", 1); outBytes += 1; }
size_t n = fySerializeDet(fyDet[i], line, sizeof(line));
if (n == 0) continue;
crc = fyCRC32Update(crc, (const uint8_t*)line, n);
outBytes += n;
}
crc = fyCRC32Update(crc, (const uint8_t*)"]", 1); outBytes += 1;
return crc;
}
// Validate a session file envelope and its payload CRC. Returns true if intact.
static bool fyValidateSessionFile(const char* path) {
if (!SPIFFS.exists(path)) return false;
File f = SPIFFS.open(path, "r");
if (!f) return false;
String hdr = f.readStringUntil('\n');
if (hdr.length() < 10 || hdr[0] != '{') { f.close(); return false; }
JsonDocument doc;
if (deserializeJson(doc, hdr) != DeserializationError::Ok) { f.close(); return false; }
if ((int)(doc["v"] | 0) != 1) { f.close(); return false; }
size_t expectedBytes = (size_t)(doc["bytes"] | 0);
uint32_t expectedCRC = 0;
const char* crcStr = doc["crc"] | "";
if (sscanf(crcStr, "%x", &expectedCRC) != 1) { f.close(); return false; }
size_t bodyOffset = hdr.length() + 1;
size_t fileSize = f.size();
if (fileSize < bodyOffset + expectedBytes) { f.close(); return false; }
size_t actualBytes = fileSize - bodyOffset;
if (actualBytes != expectedBytes) { f.close(); return false; }
uint8_t buf[256];
uint32_t crc = 0;
size_t remaining = expectedBytes;
while (remaining > 0) {
int n = f.read(buf, remaining < sizeof(buf) ? remaining : sizeof(buf));
if (n <= 0) break;
crc = fyCRC32Update(crc, buf, (size_t)n);
remaining -= (size_t)n;
} }
f.print("]");
f.close(); f.close();
fyLastSaveCount = fyDetCount; return (remaining == 0 && crc == expectedCRC);
printf("[FLOCK-YOU] Session saved: %d detections\n", fyDetCount); }
// Copy src→dst in chunks. Returns true on success.
static bool fySpiffsCopy(const char* src, const char* dst) {
File s = SPIFFS.open(src, "r");
if (!s) return false;
File d = SPIFFS.open(dst, "w");
if (!d) { s.close(); return false; }
uint8_t buf[256];
int n;
bool ok = true;
while ((n = s.read(buf, sizeof(buf))) > 0) {
if (d.write(buf, (size_t)n) != (size_t)n) { ok = false; break; }
}
s.close();
d.close();
return ok;
}
// Atomic rename: try SPIFFS rename first, fall back to copy+delete if rename fails.
static bool fyAtomicPromote(const char* src, const char* dst) {
if (SPIFFS.rename(src, dst)) return true;
if (!fySpiffsCopy(src, dst)) return false;
SPIFFS.remove(src);
return true;
}
static void fySaveSession() {
if (!fySpiffsReady || !fyMutex) return;
if (xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) != pdTRUE) {
printf("[FLOCK-YOU] Save skipped: fyMutex busy\n");
return;
}
// Pass 1: compute CRC + byte count
size_t payloadBytes = 0;
uint32_t crc = fyComputePayloadCRC(payloadBytes);
int savedCount = fyDetCount;
// Pass 2: write envelope + payload to tmp
File f = SPIFFS.open(FY_SESSION_TMP, "w");
if (!f) {
xSemaphoreGive(fyMutex); xSemaphoreGive(fyMutex);
printf("[FLOCK-YOU] Save failed: cannot open %s\n", FY_SESSION_TMP);
return;
}
f.printf("{\"v\":1,\"count\":%d,\"bytes\":%u,\"crc\":\"0x%08lX\"}\n",
savedCount, (unsigned)payloadBytes, (unsigned long)crc);
char line[512];
size_t wrote = 0;
f.write((uint8_t*)"[", 1); wrote++;
for (int i = 0; i < fyDetCount; i++) {
if (i > 0) { f.write((uint8_t*)",", 1); wrote++; }
size_t n = fySerializeDet(fyDet[i], line, sizeof(line));
if (n == 0) continue;
f.write((uint8_t*)line, n);
wrote += n;
}
f.write((uint8_t*)"]", 1); wrote++;
f.close();
xSemaphoreGive(fyMutex);
if (wrote != payloadBytes) {
printf("[FLOCK-YOU] Save WARNING: wrote %u expected %u — aborting promote\n",
(unsigned)wrote, (unsigned)payloadBytes);
return;
}
if (!fyValidateSessionFile(FY_SESSION_TMP)) {
printf("[FLOCK-YOU] Save verify FAILED — aborting promote (old session preserved)\n");
return;
}
SPIFFS.remove(FY_SESSION_FILE);
if (!fyAtomicPromote(FY_SESSION_TMP, FY_SESSION_FILE)) {
printf("[FLOCK-YOU] Promote FAILED — data in %s for recovery\n", FY_SESSION_TMP);
return;
}
fyLastSaveCount = savedCount;
printf("[FLOCK-YOU] Session saved: %d det, %u bytes, crc=0x%08lX\n",
savedCount, (unsigned)payloadBytes, (unsigned long)crc);
} }
static void fyPromotePrevSession() { static void fyPromotePrevSession() {
// Copy current session to prev_session on boot, then delete original
// NOTE: SPIFFS.rename() is unreliable on ESP32 — use copy+delete instead
if (!fySpiffsReady) return; if (!fySpiffsReady) return;
if (!SPIFFS.exists(FY_SESSION_FILE)) {
printf("[FLOCK-YOU] No prior session file to promote\n"); const char* source = nullptr;
if (fyValidateSessionFile(FY_SESSION_FILE)) {
source = FY_SESSION_FILE;
} else if (fyValidateSessionFile(FY_SESSION_TMP)) {
printf("[FLOCK-YOU] Main session corrupt/missing — recovering from tmp\n");
source = FY_SESSION_TMP;
} else {
// Legacy fallback: old format (raw array, no envelope)
if (SPIFFS.exists(FY_SESSION_FILE)) {
File f = SPIFFS.open(FY_SESSION_FILE, "r");
if (f && f.size() > 2) {
int first = f.peek();
f.close();
if (first == '[') {
source = FY_SESSION_FILE;
printf("[FLOCK-YOU] Legacy-format session detected — promoting\n");
}
} else if (f) { f.close(); }
}
}
if (!source) {
if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE);
if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP);
printf("[FLOCK-YOU] No valid prior session to promote\n");
return; return;
} }
File src = SPIFFS.open(FY_SESSION_FILE, "r"); if (!fySpiffsCopy(source, FY_PREV_FILE)) {
if (!src) { printf("[FLOCK-YOU] Failed to promote %s → %s\n", source, FY_PREV_FILE);
printf("[FLOCK-YOU] Failed to open session file for promotion\n");
return;
}
String data = src.readString();
src.close();
if (data.length() == 0) {
printf("[FLOCK-YOU] Session file empty, skipping promotion\n");
SPIFFS.remove(FY_SESSION_FILE);
return; return;
} }
// Write to prev_session (overwrite any existing) if (SPIFFS.exists(FY_SESSION_FILE)) SPIFFS.remove(FY_SESSION_FILE);
File dst = SPIFFS.open(FY_PREV_FILE, "w"); if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP);
if (!dst) {
printf("[FLOCK-YOU] Failed to create prev_session file\n");
return;
}
dst.print(data);
dst.close();
// Delete the old session file so it doesn't get re-promoted next boot File v = SPIFFS.open(FY_PREV_FILE, "r");
SPIFFS.remove(FY_SESSION_FILE); size_t sz = v ? v.size() : 0;
printf("[FLOCK-YOU] Prior session promoted: %d bytes\n", data.length()); if (v) v.close();
printf("[FLOCK-YOU] Prior session promoted from %s (%u bytes)\n", source, (unsigned)sz);
}
// Read prev_session as a raw detection JSON array (strips envelope header if present).
static void fyStreamPrevSessionBody(AsyncResponseStream* resp) {
if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { resp->print("[]"); return; }
File f = SPIFFS.open(FY_PREV_FILE, "r");
if (!f) { resp->print("[]"); return; }
int first = f.peek();
if (first == '{') f.readStringUntil('\n');
uint8_t buf[256];
int n;
size_t streamed = 0;
while ((n = f.read(buf, sizeof(buf))) > 0) {
resp->write(buf, (size_t)n);
streamed += (size_t)n;
}
f.close();
if (streamed == 0) resp->print("[]");
} }
// ============================================================================ // ============================================================================
@@ -711,7 +952,7 @@ static void writeDetectionsKML(AsyncResponseStream *resp) {
"<Style id=\"raven\"><IconStyle><color>ff4444ef</color>" "<Style id=\"raven\"><IconStyle><color>ff4444ef</color>"
"<scale>1.2</scale></IconStyle></Style>\n"); "<scale>1.2</scale></IconStyle></Style>\n");
if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(300)) == pdTRUE) { if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) {
for (int i = 0; i < fyDetCount; i++) { for (int i = 0; i < fyDetCount; i++) {
FYDetection& d = fyDet[i]; FYDetection& d = fyDet[i];
if (!d.hasGPS) continue; // Skip detections without GPS if (!d.hasGPS) continue; // Skip detections without GPS
@@ -892,14 +1133,13 @@ static void fySetupServer() {
r->send(200, "application/json", buf); r->send(200, "application/json", buf);
}); });
// API: Receive GPS from phone browser // API: Receive GPS from phone browser (atomic publish under fyGPSMutex)
fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) { fyServer.on("/api/gps", HTTP_GET, [](AsyncWebServerRequest *r) {
if (r->hasParam("lat") && r->hasParam("lon")) { if (r->hasParam("lat") && r->hasParam("lon")) {
fyGPSLat = r->getParam("lat")->value().toDouble(); double lat = r->getParam("lat")->value().toDouble();
fyGPSLon = r->getParam("lon")->value().toDouble(); double lon = r->getParam("lon")->value().toDouble();
fyGPSAcc = r->hasParam("acc") ? r->getParam("acc")->value().toFloat() : 0; float acc = r->hasParam("acc") ? r->getParam("acc")->value().toFloat() : 0;
fyGPSValid = true; fyGPSUpdate(lat, lon, acc);
fyGPSLastUpdate = millis();
r->send(200, "application/json", "{\"status\":\"ok\"}"); r->send(200, "application/json", "{\"status\":\"ok\"}");
} else { } else {
r->send(400, "application/json", "{\"error\":\"lat,lon required\"}"); r->send(400, "application/json", "{\"error\":\"lat,lon required\"}");
@@ -956,7 +1196,7 @@ static void fySetupServer() {
AsyncResponseStream *resp = r->beginResponseStream("text/csv"); AsyncResponseStream *resp = r->beginResponseStream("text/csv");
resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.csv\""); resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_detections.csv\"");
resp->println("mac,name,rssi,method,first_seen_ms,last_seen_ms,count,is_raven,raven_fw,latitude,longitude,gps_accuracy"); resp->println("mac,name,rssi,method,first_seen_ms,last_seen_ms,count,is_raven,raven_fw,latitude,longitude,gps_accuracy");
if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(500)) == pdTRUE) {
for (int i = 0; i < fyDetCount; i++) { for (int i = 0; i < fyDetCount; i++) {
FYDetection& d = fyDet[i]; FYDetection& d = fyDet[i];
if (d.hasGPS) { if (d.hasGPS) {
@@ -985,27 +1225,27 @@ static void fySetupServer() {
r->send(resp); r->send(resp);
}); });
// API: Prior session history (JSON) // API: Prior session history (JSON) — strips envelope header if present
fyServer.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) { fyServer.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) {
if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) { AsyncResponseStream *resp = r->beginResponseStream("application/json");
r->send(SPIFFS, FY_PREV_FILE, "application/json"); fyStreamPrevSessionBody(resp);
} else {
r->send(200, "application/json", "[]");
}
});
// API: Download prior session as JSON file
fyServer.on("/api/history/json", HTTP_GET, [](AsyncWebServerRequest *r) {
if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) {
AsyncWebServerResponse *resp = r->beginResponse(SPIFFS, FY_PREV_FILE, "application/json");
resp->addHeader("Content-Disposition", "attachment; filename=\"flockyou_prev_session.json\"");
r->send(resp); r->send(resp);
} else {
r->send(404, "application/json", "{\"error\":\"no prior session\"}");
}
}); });
// API: Download prior session as KML (reads JSON from SPIFFS, converts) // API: Download prior session as JSON file (body-only, envelope stripped)
fyServer.on("/api/history/json", HTTP_GET, [](AsyncWebServerRequest *r) {
if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) {
r->send(404, "application/json", "{\"error\":\"no prior session\"}");
return;
}
AsyncResponseStream *resp = r->beginResponseStream("application/json");
resp->addHeader("Content-Disposition",
"attachment; filename=\"flockyou_prev_session.json\"");
fyStreamPrevSessionBody(resp);
r->send(resp);
});
// API: Download prior session as KML (reads JSON body from SPIFFS, converts)
fyServer.on("/api/history/kml", HTTP_GET, [](AsyncWebServerRequest *r) { fyServer.on("/api/history/kml", HTTP_GET, [](AsyncWebServerRequest *r) {
if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) { if (!fySpiffsReady || !SPIFFS.exists(FY_PREV_FILE)) {
r->send(404, "application/json", "{\"error\":\"no prior session\"}"); r->send(404, "application/json", "{\"error\":\"no prior session\"}");
@@ -1013,6 +1253,8 @@ static void fySetupServer() {
} }
File f = SPIFFS.open(FY_PREV_FILE, "r"); File f = SPIFFS.open(FY_PREV_FILE, "r");
if (!f) { r->send(500, "text/plain", "read error"); return; } if (!f) { r->send(500, "text/plain", "read error"); return; }
// Strip envelope header if present
if (f.peek() == '{') f.readStringUntil('\n');
String content = f.readString(); String content = f.readString();
f.close(); f.close();
if (content.length() == 0) { if (content.length() == 0) {
@@ -1094,6 +1336,7 @@ void setup() {
digitalWrite(BUZZER_PIN, LOW); digitalWrite(BUZZER_PIN, LOW);
fyMutex = xSemaphoreCreateMutex(); fyMutex = xSemaphoreCreateMutex();
fyGPSMutex = xSemaphoreCreateMutex();
// Init SPIFFS for session persistence // Init SPIFFS for session persistence
if (SPIFFS.begin(true)) { if (SPIFFS.begin(true)) {
@@ -1205,19 +1448,29 @@ void loop() {
} }
} }
// Auto-save session to SPIFFS every 15s if detections changed // Back-fill GPS on any detections captured before the first fix (every 2s)
// Also triggers an early save 5s after first detection to minimize loss on power-cycle static unsigned long lastBackfill = 0;
if (fySpiffsReady && millis() - fyLastSave >= FY_SAVE_INTERVAL) { if (millis() - lastBackfill >= 2000) {
if (fyDetCount > 0 && fyDetCount != fyLastSaveCount) { fyBackfillGPS();
fySaveSession(); lastBackfill = millis();
} }
fyLastSave = millis();
} else if (fySpiffsReady && fyDetCount > 0 && fyLastSaveCount == 0 && // Bulletproof save cadence:
millis() - fyLastSave >= 5000) { // - within 5s of first detection (quick first-save)
// Quick first-save: persist within 5s of first detection // - any time fyDetCount increases (new unique device), throttled to 3s minimum
// - every FY_SAVE_INTERVAL (15s) as a safety net
if (fySpiffsReady && fyDetCount > 0) {
unsigned long now = millis();
bool countChanged = (fyDetCount != fyLastSaveCount);
bool minGap = (now - fyLastSave >= 3000);
bool firstSave = (fyLastSaveCount == 0 && now - fyLastSave >= 5000);
bool periodic = (now - fyLastSave >= FY_SAVE_INTERVAL);
if (firstSave || (countChanged && minGap) || periodic) {
fySaveSession(); fySaveSession();
fyLastSave = millis(); fyLastSave = millis();
} }
}
delay(100); delay(100);
} }