From 43b4918f2611610a7820243913b23a367b009fe9 Mon Sep 17 00:00:00 2001 From: rpriven Date: Sun, 22 Feb 2026 20:12:46 -0700 Subject: [PATCH] Add peak-RSSI GPS tracking and battery monitoring Track strongest signal per detection to capture GPS at closest approach to surveillance devices, rather than only first-seen position. All exports (JSON, KML, CSV, SPIFFS, dashboard, radar) updated to use best GPS. Also adds battery ADC monitoring framework (disabled by default until voltage divider hardware is wired) and uptime display in stats bar. Co-Authored-By: Claude Opus 4.6 --- src/main.cpp | 152 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 27 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index d8e0072..a764851 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -66,6 +66,18 @@ #define FY_NEOPIXEL_BRIGHTNESS 50 #define FY_NEOPIXEL_DETECTION_BRIGHTNESS 200 +// Battery monitoring +// XIAO ESP32-S3 has no dedicated battery ADC pin. To enable battery monitoring: +// 1. Wire a voltage divider (2x 200K resistors) from BAT+ to an ADC pin +// 2. Set BATTERY_ADC_PIN to that GPIO number (e.g. 1 for A0/D0) +// 3. The code reads voltage, applies divider ratio, maps to 0-100% +// Set to -1 to disable (no hardware voltage divider connected) +#define BATTERY_ADC_PIN -1 // -1 = disabled, set to GPIO with voltage divider +#define BATTERY_SAMPLES 16 // ADC samples to average (noise reduction) +#define BATTERY_FULL_V 4.2f // LiPo full charge voltage +#define BATTERY_EMPTY_V 3.0f // LiPo cutoff voltage +#define BATTERY_DIVIDER 2.0f // Voltage divider ratio (2x 200K = /2) + // BLE scanning #define BLE_SCAN_DURATION 2 // seconds per scan #define BLE_SCAN_INTERVAL 3000 // ms between scans @@ -166,11 +178,17 @@ struct FYDetection { int count; bool isRaven; char ravenFW[16]; - // GPS from phone (wardriving) + // GPS from phone (wardriving) — first detection position double gpsLat; double gpsLon; float gpsAcc; bool hasGPS; + // Peak-RSSI GPS — position at strongest signal (closest approach to device) + int bestRSSI; // Strongest (least negative) RSSI seen + double bestGPSLat; + double bestGPSLon; + float bestGPSAcc; + bool hasBestGPS; }; static FYDetection fyDet[MAX_DETECTIONS]; @@ -221,6 +239,31 @@ static unsigned long fyLastSave = 0; static int fyLastSaveCount = 0; // Track changes to avoid unnecessary writes static bool fySpiffsReady = false; +// Battery monitoring +static int fyBatteryPercent = -1; // -1 = no ADC available +static float fyBatteryVoltage = 0; +static unsigned long fyLastBattRead = 0; +#define BATTERY_READ_INTERVAL 10000 // Read every 10 seconds + +static void fyReadBattery() { +#if BATTERY_ADC_PIN >= 0 + if (millis() - fyLastBattRead < BATTERY_READ_INTERVAL && fyLastBattRead > 0) return; + fyLastBattRead = millis(); + uint32_t raw = 0; + for (int i = 0; i < BATTERY_SAMPLES; i++) { + raw += analogReadMilliVolts(BATTERY_ADC_PIN); + } + float mv = (float)raw / BATTERY_SAMPLES; + fyBatteryVoltage = mv * BATTERY_DIVIDER / 1000.0f; + int pct = (int)((fyBatteryVoltage - BATTERY_EMPTY_V) / (BATTERY_FULL_V - BATTERY_EMPTY_V) * 100.0f); + if (pct > 100) pct = 100; + if (pct < 0) pct = 0; + fyBatteryPercent = pct; +#else + fyBatteryPercent = -1; +#endif +} + // ============================================================================ // AUDIO SYSTEM // ============================================================================ @@ -612,6 +655,14 @@ static int fyAddDetection(const char* mac, const char* name, int rssi, } // Update GPS on every re-sighting (captures movement) fyAttachGPS(fyDet[i]); + // Track peak RSSI — strongest signal = closest approach to device + if (rssi > fyDet[i].bestRSSI && fyGPSIsFresh()) { + fyDet[i].bestRSSI = rssi; + fyDet[i].bestGPSLat = fyGPSLat; + fyDet[i].bestGPSLon = fyGPSLon; + fyDet[i].bestGPSAcc = fyGPSAcc; + fyDet[i].hasBestGPS = true; + } xSemaphoreGive(fyMutex); return i; } @@ -629,14 +680,22 @@ static int fyAddDetection(const char* mac, const char* name, int rssi, } } d.rssi = rssi; + d.bestRSSI = rssi; // First sighting = initial best strncpy(d.method, method, sizeof(d.method) - 1); d.firstSeen = millis(); d.lastSeen = millis(); d.count = 1; d.isRaven = isRaven; strncpy(d.ravenFW, ravenFW ? ravenFW : "", sizeof(d.ravenFW) - 1); - // Attach GPS from phone + // Attach GPS from phone (first-seen position) fyAttachGPS(d); + // Initialize best GPS to first-seen GPS + if (d.hasGPS) { + d.bestGPSLat = d.gpsLat; + d.bestGPSLon = d.gpsLon; + d.bestGPSAcc = d.gpsAcc; + d.hasBestGPS = true; + } int idx = fyDetCount++; xSemaphoreGive(fyMutex); return idx; @@ -765,11 +824,17 @@ static void writeDetectionsJSON(AsyncResponseStream *resp) { fyDet[i].mac, fyDet[i].name, fyDet[i].rssi, fyDet[i].method, fyDet[i].firstSeen, fyDet[i].lastSeen, fyDet[i].count, fyDet[i].isRaven ? "true" : "false", fyDet[i].ravenFW); - // Append GPS if present + // Append GPS if present (first-seen position) if (fyDet[i].hasGPS) { resp->printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", fyDet[i].gpsLat, fyDet[i].gpsLon, fyDet[i].gpsAcc); } + // Append best GPS (peak-RSSI = closest approach position) + if (fyDet[i].hasBestGPS) { + resp->printf(",\"best_rssi\":%d,\"best_gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", + fyDet[i].bestRSSI, + fyDet[i].bestGPSLat, fyDet[i].bestGPSLon, fyDet[i].bestGPSAcc); + } resp->print("}"); } xSemaphoreGive(fyMutex); @@ -801,6 +866,10 @@ static void fySaveSession() { if (d.hasGPS) { f.printf(",\"gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", d.gpsLat, d.gpsLon, d.gpsAcc); } + if (d.hasBestGPS) { + f.printf(",\"best_rssi\":%d,\"best_gps\":{\"lat\":%.8f,\"lon\":%.8f,\"acc\":%.1f}", + d.bestRSSI, d.bestGPSLat, d.bestGPSLon, d.bestGPSAcc); + } f.print("}"); } f.print("]"); @@ -866,7 +935,11 @@ static void writeDetectionsKML(AsyncResponseStream *resp) { if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(300)) == pdTRUE) { for (int i = 0; i < fyDetCount; i++) { FYDetection& d = fyDet[i]; - if (!d.hasGPS) continue; // Skip detections without GPS + if (!d.hasGPS && !d.hasBestGPS) continue; // Skip detections without any GPS + // Use best GPS (closest approach) if available, else first-seen GPS + double pinLat = d.hasBestGPS ? d.bestGPSLat : d.gpsLat; + double pinLon = d.hasBestGPS ? d.bestGPSLon : d.gpsLon; + float pinAcc = d.hasBestGPS ? d.bestGPSAcc : d.gpsAcc; resp->print("\n"); resp->printf("%s\n", d.mac); resp->printf("#%s\n", d.isRaven ? "raven" : "det"); @@ -874,13 +947,14 @@ static void writeDetectionsKML(AsyncResponseStream *resp) { if (d.name[0]) resp->printf("Name: %s
", d.name); resp->printf("Method: %s
" "RSSI: %d dBm
" + "Best RSSI: %d dBm
" "Count: %d
", - d.method, d.rssi, d.count); + d.method, d.rssi, d.hasBestGPS ? d.bestRSSI : d.rssi, d.count); if (d.isRaven) resp->printf("Raven FW: %s
", d.ravenFW); - resp->printf("Accuracy: %.1f m", d.gpsAcc); + resp->printf("Accuracy: %.1f m", pinAcc); resp->print("]]>\n"); resp->printf("%.8f,%.8f,0\n", - d.gpsLon, d.gpsLat); + pinLon, pinLat); resp->print("
\n"); } xSemaphoreGive(fyMutex); @@ -974,6 +1048,7 @@ h4{color:var(--a1);font-size:14px;margin-bottom:8px}
0
RAVEN
ON
BLE+WiFi
TAP
GPS
+
--
UPTIME
@@ -1077,7 +1152,7 @@ return '
'+d.mac+(d.name?'×'+d.count+'' +(d.raven?'RAVEN '+d.fw+'':'') -+(d.gps?'◉ '+d.gps.lat.toFixed(5)+','+d.gps.lon.toFixed(5)+'':'no gps') ++(d.best_gps?'◉ '+d.best_gps.lat.toFixed(5)+','+d.best_gps.lon.toFixed(5)+'':d.gps?'◉ '+d.gps.lat.toFixed(5)+','+d.gps.lon.toFixed(5)+'':'no gps') +'
';} // === STATS === function stats(){document.getElementById('sT').textContent=D.length; @@ -1089,6 +1164,10 @@ else if(s.gps_src==='phone'){g.textContent=s.gps_tagged+'/'+s.total;g.style.colo else if(s.gps_hw_detected){g.textContent=s.gps_sats+'sat';g.style.color='#facc15';gl.textContent='NO FIX';} else{g.textContent='TAP';g.style.color='#ef4444';gl.textContent='GPS';} if(s.device_lat&&s.device_lon)_dGPS={lat:s.device_lat,lon:s.device_lon};else _dGPS=null; +let bt=document.getElementById('sBat'),bl=document.getElementById('sBatL'); +if(s.batt_pct>=0){bt.textContent=s.batt_pct+'%';bl.textContent=s.batt_v.toFixed(1)+'V'; +bt.style.color=s.batt_pct>20?'#22c55e':s.batt_pct>10?'#facc15':'#ef4444';} +else{bt.textContent=s.uptime;bl.textContent='UPTIME';} }).catch(()=>{});} // === RADAR === function togRadar(){_rO=!_rO; @@ -1124,8 +1203,8 @@ ctx.fillStyle=grd;ctx.beginPath();ctx.arc(cx,cy,mR,0,Math.PI*2);ctx.fill(); // blips D.forEach(det=>{ const rssi=Math.abs(det.rssi),dist=Math.max(0,Math.min(1,(rssi-30)/60))*mR; -let ang; -if(_dGPS&&det.gps)ang=bearing(_dGPS.lat,_dGPS.lon,det.gps.lat,det.gps.lon); +let ang;const dg=det.best_gps||det.gps; +if(_dGPS&&dg)ang=bearing(_dGPS.lat,_dGPS.lon,dg.lat,dg.lon); else ang=(macHash(det.mac)%628)/100; const bx=cx+dist*Math.cos(ang),by=cy+dist*Math.sin(ang); const t=dtype(det);let col=cs.getPropertyValue('--bl-other').trim(); @@ -1235,20 +1314,26 @@ static void fySetupServer() { const char* gpsSrc = "none"; if (fyGPSIsHardware && fyHWGPSFix) gpsSrc = "hw"; else if (fyGPSIsFresh()) gpsSrc = "phone"; - char buf[448]; + unsigned long upSec = millis() / 1000; + int upH = upSec / 3600, upM = (upSec % 3600) / 60; + char buf[512]; snprintf(buf, sizeof(buf), "{\"total\":%d,\"raven\":%d,\"ble\":\"active\",\"wifi\":\"active\"," "\"wifi_det\":%d," "\"gps_valid\":%s,\"gps_age\":%lu,\"gps_tagged\":%d," "\"gps_src\":\"%s\",\"gps_sats\":%d,\"gps_hw_detected\":%s," - "\"device_lat\":%.8f,\"device_lon\":%.8f}", + "\"device_lat\":%.8f,\"device_lon\":%.8f," + "\"batt_pct\":%d,\"batt_v\":%.2f," + "\"uptime\":\"%dh%02dm\"}", fyDetCount, raven, fyWifiDetCount, fyGPSIsFresh() ? "true" : "false", fyGPSValid ? (millis() - fyGPSLastUpdate) : 0UL, withGPS, gpsSrc, fyHWGPSSats, fyHWGPSDetected ? "true" : "false", - fyGPSLat, fyGPSLon); + fyGPSLat, fyGPSLon, + fyBatteryPercent, fyBatteryVoltage, + upH, upM); r->send(200, "application/json", buf); }); @@ -1315,21 +1400,23 @@ static void fySetupServer() { fyServer.on("/api/export/csv", HTTP_GET, [](AsyncWebServerRequest *r) { AsyncResponseStream *resp = r->beginResponseStream("text/csv"); resp->addHeader("Content-Disposition", "attachment; filename=\"dantir_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,best_rssi,best_latitude,best_longitude,best_gps_accuracy"); if (fyMutex && xSemaphoreTake(fyMutex, pdMS_TO_TICKS(200)) == pdTRUE) { for (int i = 0; i < fyDetCount; i++) { FYDetection& d = fyDet[i]; + resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\"", + d.mac, d.name, d.rssi, d.method, + d.firstSeen, d.lastSeen, d.count, + d.isRaven ? "true" : "false", d.ravenFW); if (d.hasGPS) { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",%.8f,%.8f,%.1f\n", - 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); + resp->printf(",%.8f,%.8f,%.1f", d.gpsLat, d.gpsLon, d.gpsAcc); } else { - resp->printf("\"%s\",\"%s\",%d,\"%s\",%lu,%lu,%d,%s,\"%s\",,,\n", - d.mac, d.name, d.rssi, d.method, - d.firstSeen, d.lastSeen, d.count, - d.isRaven ? "true" : "false", d.ravenFW); + resp->print(",,,"); + } + if (d.hasBestGPS) { + resp->printf(",%d,%.8f,%.8f,%.1f\n", d.bestRSSI, d.bestGPSLat, d.bestGPSLon, d.bestGPSAcc); + } else { + resp->print(",,,\n"); // trailing newline included } } xSemaphoreGive(fyMutex); @@ -1395,21 +1482,27 @@ static void fySetupServer() { if (!err && doc.is()) { int placed = 0; for (JsonObject d : doc.as()) { + // Prefer best_gps (peak RSSI = closest approach), fall back to first-seen gps + JsonObject bgps = d["best_gps"]; JsonObject gps = d["gps"]; - if (!gps || !gps.containsKey("lat")) continue; + JsonObject pinGPS = (bgps && bgps["lat"]) ? bgps : gps; + if (!pinGPS || !pinGPS["lat"]) continue; bool isRaven = d["raven"] | false; resp->printf("%s\n", d["mac"] | "?"); resp->printf("#%s\n", isRaven ? "raven" : "det"); resp->print("() && strlen(d["name"] | "") > 0) resp->printf("Name: %s
", d["name"] | ""); - resp->printf("Method: %s
RSSI: %d
Count: %d", - d["method"] | "?", d["rssi"] | 0, d["count"] | 1); + resp->printf("Method: %s
RSSI: %d
", + d["method"] | "?", d["rssi"] | 0); + if (d["best_rssi"]) + resp->printf("Best RSSI: %d
", d["best_rssi"] | 0); + resp->printf("Count: %d", d["count"] | 1); if (isRaven && d["fw"].is()) resp->printf("
Raven FW: %s", d["fw"] | ""); resp->print("]]>
\n"); resp->printf("%.8f,%.8f,0\n", - (double)(gps["lon"] | 0.0), (double)(gps["lat"] | 0.0)); + (double)(pinGPS["lon"] | 0.0), (double)(pinGPS["lat"] | 0.0)); resp->print("
\n"); placed++; } @@ -1486,10 +1579,14 @@ void setup() { printf("[DANTIR] SPIFFS init failed - no persistence\n"); } + // Initialize battery monitoring + fyReadBattery(); + printf("\n========================================\n"); printf(" DANTIR Surveillance Counter-Watcher\n"); printf(" Buzzer: %s\n", fyBuzzerOn ? "ON" : "OFF"); printf(" GPS: auto-detect (L76K on D6/D7)\n"); + printf(" Battery ADC: %s\n", BATTERY_ADC_PIN >= 0 ? "enabled" : "disabled (no voltage divider)"); printf("========================================\n"); // Init BLE scanner FIRST -- start scanning immediately @@ -1539,6 +1636,7 @@ void setup() { void loop() { fyProcessHardwareGPS(); fyUpdatePixel(); + fyReadBattery(); // BLE scanning cycle if (millis() - fyLastBleScan >= BLE_SCAN_INTERVAL && !fyBLEScan->isScanning()) {