Community project

ESP32 Radio Firmware Upgrade

ESP32
Photo of ESP32 Radio Firmware Upgrade
Generated with AI

tianguisnetor

Published October 27, 2026

This guide walks through upgrading firmware on an ESP32-based radio device that combines weather display, GPS positioning, and animated sky visualization. The project integrates a color display, microSD card storage, GPS module, and WiFi connectivity to create a smart radio interface with real-time weather animations, automatic timezone detection, and moon phase tracking.

Following this guide, builders will learn to prepare the microSD card, verify display and GPS functionality, configure WiFi and time synchronization, customize language and timezone settings, and test continuous detection modes. The complete firmware source, wiring diagram, and step-by-step assembly instructions ensure a smooth upgrade process from recovery preparation through final battery and animation verification.

Wiring diagram

Assemble it in 12 steps

1. Conserva la recuperación

Conserva una copia del firmware original del Marauder v6.1. Sentinel no es el firmware oficial completo.

2. Prepara la microSD

Con el equipo apagado, inserta una microSD FAT32. No se formatea ni se borra. El arranque intenta poner la tarjeta en modo SPI antes de iniciar pantalla y táctil, porque comparten el bus. No garantiza resolver un fallo físico o de tarjeta. El montaje prueba 400 kHz, 1 MHz y 4 MHz. Si se monta, guarda automáticamente eventos en /sentinel/events.log en lotes cada 5 segundos y la posición GPS con ciudad cercana cada minuto cuando hay FIX. Exporta las 7307 ciudades a /sentinel/cities-v1.csv por bloques (aproximadamente un minuto); la consulta sigue usando la copia integrada para funcionar sin SD. Guardar también añade las entradas visibles y la posición GPS reciente a /div-sentinel.txt. En Sistema, SD/GPS reintenta montar. Sin tarjeta solo mantiene hasta 32 eventos pendientes en RAM; no sobreviven al apagado. Una tarjeta de 8 a 32 GB FAT32 es una opción sencilla; haz copia antes de formatearla fuera del equipo. Apaga antes de insertar o retirar la tarjeta, y espera al menos 5 segundos después del último evento antes de apagar. Los registros contienen nombres y ubicaciones; protege la tarjeta. No retires la tarjeta durante una escritura.

3. Comprueba pantalla y GPS

La pantalla gira 180 grados y solicita una nueva calibración táctil la primera vez. En la pantalla principal, CALIBRAR (abajo a la derecha) permite repetir el ajuste táctil; también está en Sistema; suelta el dedo y toca el centro de cada marca; se guarda para los próximos arranques. El filtrado exige un contacto estable y soltar antes de otra acción. Sistema y después SD/GPS abre el estado de tarjeta y receptor. El soporte GPS escucha el conector original v6.1 por GPIO4 y detecta 9600, 38400, 115200, 4800, 19200 o 57600 baudios sin reprogramar el receptor. Si tu unidad no tiene receptor GPS instalado, el firmware no puede obtener posición; no añadas cables basándote solo en esta pantalla. Para obtener señal, lleva el receptor a un lugar con cielo despejado y espera varios minutos.

4. Prueba la deteccion continua

BLE actualiza contactos mientras escucha y repite barridos de 3 segundos. Monitor alterna Wi-Fi y BLE; no son simultaneos. Los puntos desaparecen tras 12 segundos sin recibirse; eso no demuestra que el aparato se haya alejado. El radar representa intensidad, no direccion ni distancia real. GPS muestra velocidad en mph, altura en metros, coordenadas, satelites usados y FIX. La base integrada en flash contiene 7307 ciudades de SimpleMaps y no necesita Internet ni SD. Cerca indica una ciudad de referencia a menos de 50 km, no el municipio exacto; fuera de cobertura no inventa una ciudad. Toca GPS en el radar o el botón GPS inferior para ver coordenadas, altura, FIX y ciudad. Abrir GPS detiene los escaneos para conservar la página. Comprueba GPS al aire libre con cielo despejado. La segunda página GPS muestra velocidad del puerto, bytes recibidos, mensajes NMEA válidos, errores de checksum y HDOP. RX sin datos no equivale a falta de satélites. Los contactos muestran nombres cuando los anuncios los incluyen; círculos huecos indican más de 8 segundos sin recibirse y desaparecen a los 12 segundos. La interfaz completa se compone en memoria si hay un bloque de RAM suficiente; en caso contrario conserva el búfer reducido del HUD.

5. Monitor AUTO e IDLE

Pulsa Monitor para activar AUTO y vuelve a pulsarlo para detenerlo en IDLE; también puedes tocar el indicador superior. AUTO alterna barridos Wi-Fi pasivos de aproximadamente 2 segundos y escucha BLE de 3 segundos, no simultáneos. BLE actualiza los contactos al recibir anuncios; Wi-Fi se actualiza al terminar cada barrido. No es seguimiento instantáneo de todas las bandas. Las etiquetas con interrogación son indicios. AirTag/FindMy no distingue un AirTag de otros accesorios Apple compatibles. Google Tag usa datos FEAA de tipo 40 o 41, no cualquier baliza Eddystone. Dron Remote-ID busca datos de servicio BLE FFFA; no decodifica posición de drones ni Remote-ID Wi-Fi. Cámaras, Ring y algunos drones se sugieren por nombre y pueden dar falsos positivos. No detecta cámaras sin radio, equipos silenciosos ni señales exclusivas de 5 GHz. Usa solo en entornos autorizados. Comprueba físicamente que AUTO cambia a IDLE, no parpadea y elimina puntos tras 12 segundos sin anuncios.

6. Nuevo inicio, reloj y conexión Wi-Fi

El nuevo inicio muestra un reloj grande, clima y estado de red y tarjeta. MENU abre una página independiente; RADAR conserva la exploración existente y tocar SENTINEL en su cabecera vuelve al inicio. RED WI-FI permite buscar redes de 2,4 GHz y tocar un nombre. Introduce la clave con el teclado; MAY alterna mayúsculas, SIM abre números y símbolos, ESP añade un espacio y BOR elimina el último carácter. Pulsa OK, también para una red abierta sin clave, y espera hasta 20 segundos. No admite acceso empresarial ni portales cautivos. La contraseña se oculta y no se registra en SD; se guarda en la memoria del equipo para reconectar tras reiniciar. Protege físicamente el dispositivo. Desconectar corta la asociación, no borra registros. Usa exclusivamente redes autorizadas. El reloj obtiene fecha y hora UTC del GPS con FIX y mensajes recientes, sin Wi-Fi; también solicita sincronización al conectar a Internet mediante SNTP, con time.nist.gov (UTC de NIST basado en patrones atómicos), pool.ntp.org y time.cloudflare.com como respaldo; vuelve a sincronizar aproximadamente cada hora mientras hay conexión. No es una conexión directa a un reloj atómico ni garantiza precisión atómica; la red puede bloquear NTP. Sin recepción continúa con el reloj interno mientras siga encendido. RELOJ / GPS indica recepción de posición reciente; no prueba la precisión del reloj. Inicialmente muestra UTC. Con GPS e Internet consulta TimeAPI por coordenadas para obtener zona, desfase (también medias horas) y horario de verano activo. Reconsulta cada 60 segundos cuando la red y los barridos lo permiten; MENU > ZONA AUTO fuerza reintento. Sin conexión muestra zona sin actualizar; no contiene reglas mundiales offline. Necesita sincronizar de nuevo tras apagar. TimeAPI recibe coordenadas mediante TLS sin verificar certificado; la respuesta podría ser suplantada.

7. Comprueba el clima animado

Conecta Wi-Fi y espera un FIX GPS real. El clima consulta Open-Meteo para esas coordenadas, con temperatura seleccionable en MENU > GRADOS C/F (se conserva al apagar), viento en km/h, icono animado y antigüedad en minutos. Es información meteorológica de un modelo, no una medida local del equipo. El GPS no recibe clima; sin Internet muestra el motivo o conserva la última consulta en RAM como CLIMA / GUARDADO, con su antigüedad; no es clima actualizado y se pierde al apagar. Solicita actualización cada 60 segundos, en cualquier página, cuando hay Wi-Fi y no hay un barrido o conexión en curso; no superpone peticiones. Los barridos, la falta de red o los errores pueden retrasarla. El proveedor no necesariamente publica un dato nuevo cada minuto; tocar la tarjeta del clima solicita un reintento. Las coordenadas se envían a Open-Meteo mediante HTTP sin cifrado; no se envían contraseñas ni registros, pero la ubicación y la respuesta pueden ser observadas o alteradas en la red. No uses esta función si necesitas privacidad de ubicación. Servicio público para uso no comercial, atribución Open-Meteo (CC BY 4.0). El icono solo se anima cuando existen datos. Distingue despejado con sol o luna, nubes, niebla, lluvia, chubascos intermitentes, nieve y tormenta según el código meteorológico. DIA/NOCHE procede de is_day de Open-Meteo, se actualiza con la consulta y puede quedar desfasado sin Internet. Comprueba físicamente teclado, conexión, reloj, clima y retorno al radar; una carga correcta no valida esas funciones. No se han cambiado conexiones internas ni controladores de pantalla, SD o táctil.

8. Idiomas y zona automatica

MENU > IDIOMA abre Espanol, English, chino simplificado, Francais, Deutsch y Portugues; la seleccion se conserva al reiniciar. Se traducen los controles principales, reloj y estados meteorologicos; algunos mensajes tecnicos y registros siguen en espanol. La fuente china esta integrada. La zona se consulta al conectar y cada 60 segundos cuando la red y los barridos lo permiten, sin tener que dejar abierto Inicio. Con FIX GPS se usa la posicion y el desfase de Open-Meteo, con TimeAPI como consulta adicional. Sin FIX se consulta ipwho.is por la IP publica; es aproximado y una VPN puede dar otra zona. El clima sigue requiriendo GPS. Estas consultas necesitan Internet; sin el no hay deteccion mundial offline. IPwho.is recibe la IP publica; las consultas HTTPS existentes no verifican certificado. Comprueba que el nombre de zona y desfase coincidan con tu ubicacion, especialmente al viajar o cambiar horario de verano. Cambiar idioma no cambia la zona. El menu de idioma conserva las mismas regiones tactiles y no modifica controladores de SD, pantalla o radios.

9. Formato del reloj y bateria

Toca la tarjeta del reloj para alternar 24H y 12H con AM/PM. La eleccion se conserva tras apagar. La cabecera muestra el nivel aproximado del IP5306 original, actualizado cada 3 segundos en pasos de 25 por ciento; no es un medidor de autonomia ni confirma que este cargando. Si el medidor no responde muestra --% y no inventa carga. Comprueba ambos formatos y la lectura en tu unidad. No se necesitan cables nuevos.

10. Luna y animaciones del cielo

Inicio > LUNA muestra la fase media aproximada calculada con la fecha UTC del GPS o NTP, nombre, edad e iluminación. No es una efeméride de precisión y no depende de Internet una vez sincronizado el reloj mientras siga encendido. El botón inferior alterna fase real aproximada y demostraciones de eclipse solar y lunar, expresamente rotuladas DEMO; no predice eclipses ni confirma visibilidad local. INICIO vuelve al reloj. El clima mueve rayos solares, nubes, lluvia, nieve y niebla; con viento de al menos 5 km/h añade trazos de viento. Las animaciones usan movimiento continuo por tiempo, nubes en capas, rayos solares, estrellas, copos y textura lunar. Se intenta actualizar hasta 10 veces por segundo solo en las páginas visibles con búfer disponible, o aproximadamente 3 sin él; no es una tasa garantizada. Se componen en RAM y siguen integradas en el firmware; no se leen fotogramas desde la SD porque su enlace lento puede bloquear el táctil y los registros. No necesitas copiar archivos. Representan el último dato de Open-Meteo, no mediciones instantáneas; revisa su antigüedad. La fase lunar evoluciona con el tiempo real, no acelera ficticiamente el mes. Comprueba en pantalla fluidez, legibilidad y respuesta táctil; una carga correcta no demuestra el resultado visual.

11. Inspector de contactos en vivo

Toca el radar o las tarjetas Wi-Fi/BLE del HUD para abrir CONTACTOS sin detener Monitor. Filtra TODOS, WI-FI o BLE y toca una fila para ver identificador, señal y segundos desde la última recepción. Se ordenan de mayor a menor intensidad; PAGINA recorre grupos de cuatro y RADAR regresa sin detener la exploración. Los contactos caducan a los 12 segundos; un detalle abierto muestra CONTACTO CADUCADO en lugar de reutilizar datos de otra fila. La lista se refresca una vez por segundo y solo incluye observaciones recientes, no el historial. No indica dirección, distancia ni identidad confirmada. Comprueba desaparición y respuesta táctil con tus propios dispositivos.

12. Firmware USB y fluidez

OTA eliminado, incluido su servidor y menú. Actualiza mediante Deploy por USB. La pantalla utiliza un búfer de 16 colores y solo envía las franjas que cambian; no borra la pantalla física entre fotogramas. Esto libera 38400 bytes respecto al búfer anterior de 256 colores cuando este se podía reservar. Las consultas HTTPS se omiten si no hay memoria suficiente; en ese caso no se inventa una zona horaria. El clima HTTP y las zonas ya obtenidas se conservan según las reglas anteriores. Los registros se escriben en lotes de hasta ocho entradas cada cinco segundos; espera a que terminen las escrituras antes de apagar. Comprueba físicamente reloj, radar, Monitor, teclado, táctil y SD; la compilación y el arranque no garantizan ausencia total de pausas ni validan el resultado visual. Se conservan credenciales y calibración.

Deploy the firmware

#include <Arduino.h>
#include <LovyanGFX.hpp>
#include <WiFi.h>
#include <HTTPClient.h>
#include <time.h>
#include <esp_wifi.h>
#include <NimBLEDevice.h>
#include <esp32-hal-bt.h>
// Keep BLE controller memory before setup(), not merely after startup.
#if __has_include("esp32-hal-alloc-ble-mem.h")
#include "esp32-hal-alloc-ble-mem.h"
#else
extern "C" bool btInUse() { return true; }
#endif
#include <SPI.h>
#include <Wire.h>
#include <SD.h>
#include <Preferences.h>
#include <atomic>
#include <math.h>
#include <TinyGPS++.h>
#include "cities.h"
uint16_t uiColor[]={0x0001,0x4FFB,0x10C4,0x21C8,0xFEA8,0xFFFF,0xAD75,0x8C92,0x032A,0xCE79,0x07DF};
#define BG uiColor[0]
#define ACCENT uiColor[1]
#define PANEL uiColor[2]
#define GRID uiColor[3]
#define AMBER uiColor[4]
TinyGPSPlus gps;
TinyGPSCustom gpQuality(gps,"GPGGA",6), gnQuality(gps,"GNGGA",6);
TinyGPSCustom gpStatus(gps,"GPRMC",2), gnStatus(gps,"GNRMC",2);
bool gpsHasFix() {
  if(gpStatus.isValid() && gpStatus.age()<5000 && gpStatus.age()<=gnStatus.age() && gpStatus.value()[0]!='A')return false;
  if(gnStatus.isValid() && gnStatus.age()<5000 && gnStatus.age()<gpStatus.age() && gnStatus.value()[0]!='A')return false;
  bool recent=gps.location.isValid() && gps.location.age()<5000;
  if(gpQuality.isValid() && gpQuality.age()<5000 && gpQuality.age()<=gnQuality.age())return recent && atoi(gpQuality.value())>0;
  if(gnQuality.isValid() && gnQuality.age()<5000)return recent && atoi(gnQuality.value())>0;
  return recent;
}
HardwareSerial gpsPort(2);
const uint32_t gpsBauds[]={9600,38400,115200,4800,19200,57600};
int gpsBaudIndex=0;
uint32_t gpsProbeAt=0, gpsValidAt=0, gpsLastByte=0, gpsBytes=0;
bool gpsSeen=false;

// Flash-resident gazetteer; never claims municipal boundaries or an exact address.
String nearbyCity() {
  if(!gpsHasFix())return "Ciudad: esperando FIX";
  static String cached="Ciudad: --";
  static double lastLat=1000,lastLon=1000;
  double lat=gps.location.lat(),lon=gps.location.lng();
  if(lastLat!=1000 && TinyGPSPlus::distanceBetween(lat,lon,lastLat,lastLon)<250)return cached;
  lastLat=lat;lastLon=lon;
  double best=1e30;size_t idx=0;
  double scale=cos(lat*DEG_TO_RAD);
  for(size_t i=0;i<cityCount;i++) {
    double dx=remainder((double)cities[i].lon-lon,360.0)*scale;
    double dy=cities[i].lat-lat, d=dx*dx+dy*dy;
    if(d<best){best=d;idx=i;}
  }
  double km=TinyGPSPlus::distanceBetween(lat,lon,cities[idx].lat,cities[idx].lon)/1000.0;
  cached=km<=50?"Cerca: "+String(cities[idx].name):"Ciudad: fuera de cobertura";
  return cached;
}

// One Arduino SPI owner for SD and touch. Avoid a second touch bus init
// and explicitly restore full-duplex SPI settings after SD transactions.
SPIClass storageBus(VSPI);
class SharedTouch : public lgfx::Touch_XPT2046 {
public:
  bool init() override {
    pinMode(21,OUTPUT);digitalWrite(21,HIGH);
    return true;
  }
  uint_fast8_t getTouchRaw(lgfx::touch_point_t* tp, uint_fast8_t count) override {
    if(!count)return 0;
    tp->size=0;
    uint8_t tx[57]={},rx[57]={};
    // XPT2046 pipelined conversion sequence, as in LovyanGFX's driver.
    for(int j=0;j<7;j++){
      tx[j*8]=0x91;tx[j*8+2]=0xB1;
      tx[j*8+4]=0xD1;tx[j*8+6]=0xC1;
    }
    tx[56]=0x80;
    digitalWrite(17,HIGH);digitalWrite(14,HIGH);
    storageBus.beginTransaction(SPISettings(1000000,MSBFIRST,SPI_MODE0));
    digitalWrite(21,LOW);
    storageBus.transferBytes(tx,rx,sizeof(tx));
    digitalWrite(21,HIGH);
    storageBus.endTransaction();
    uint16_t xs[7],ys[7],zs[7];int nx=0,ny=0,nz=0;
    for(int j=0;j<7;j++){
      uint8_t* d=rx+j*8;
      int x=(d[5]<<8|d[6])>>3,y=(d[1]<<8|d[2])>>3;
      int z=0x3200+y-x+(((d[3]<<8|d[4])-(d[7]<<8|d[8]))>>1);
      if(x>128 && x<=3968)xs[nx++]=x;
      if(y>128 && y<=3968)ys[ny++]=y;
      if(z>0)zs[nz++]=z;
    }
    if(nx<3 || ny<3 || nz<3)return 0;
    std::sort(xs,xs+nx);std::sort(ys,ys+ny);std::sort(zs,zs+nz);
    tp->x=xs[nx/2];tp->y=ys[ny/2];tp->size=zs[nz/2]>>8;
    return tp->size?1:0;
  }
};
// Official Marauder v6.1: User_Setup_og_marauder.h and configs.h.
class Screen : public lgfx::LGFX_Device {
  lgfx::Bus_SPI bus;
  lgfx::Panel_ILI9341 panel;
  SharedTouch touch;
public:
  Screen() {
    auto b=bus.config(); b.spi_host=SPI3_HOST; b.pin_sclk=18;
    b.pin_mosi=23; b.pin_miso=19; b.pin_dc=16;
    b.freq_write=27000000; b.freq_read=16000000; b.spi_3wire=false;
    bus.config(b); panel.setBus(&bus);
    auto p=panel.config(); p.pin_cs=17; p.pin_rst=5;
    p.panel_width=240; p.panel_height=320; p.bus_shared=true;
    panel.config(p);
    auto t=touch.config();t.spi_host=SPI3_HOST;t.pin_sclk=18;t.pin_mosi=23;t.pin_miso=19;
    t.pin_cs=21;t.pin_int=-1;t.bus_shared=true;t.freq=1000000;
    t.x_min=280;t.x_max=3850;t.y_min=320;t.y_max=3750;
    touch.config(t);panel.setTouch(&touch);setPanel(&panel);
  }
} lcd;
lgfx::LGFX_Sprite hudBuffer(&lcd);
lgfx::LGFXBase* canvas=&lcd;
bool hudBuffered=false, fullBuffered=false;
bool displayReady=false;
// SD, display and touch are serviced sequentially on the main task.
NimBLEScan* ble=nullptr;
std::atomic<int> bleInitState{0}; // 0 idle, 1 starting, 2 ready, -1 failed
uint32_t bleStarted=0;
int bleSeconds=-1;
void initBleTask(void*) {
  Serial.printf("BLE init: RAM %u, bloque %u\n",ESP.getFreeHeap(),ESP.getMaxAllocHeap());
  bool ok=NimBLEDevice::init("Sentinel");
  Serial.printf("BLE init: %s\n",ok?"OK":"ERROR");
  bleInitState.store(ok?2:-1);
  vTaskDelete(nullptr);
}
String rows[40]; int count=0, page=0, view=0;
String status="Listo";
bool sdOK=false, scanning=false, dirty=true;
String sdState="Sin tarjeta";
String logPending[32];
int logHead=0,logSize=0;
uint32_t logDropped=0,lastStorage=0;
size_t cityExport=0;
bool exportingCities=false;
void queueLog(const String& line) {
  if(logSize==32){logHead=(logHead+1)%32;logSize--;logDropped++;}
  logPending[(logHead+logSize)%32]=line;logSize++;
}
void storageFailure() {
  sdOK=false;exportingCities=false;sdState="Error de escritura";dirty=true;
  Serial.println("microSD: error de escritura; registro en RAM limitado");
}
uint32_t lastTap=0;
bool monitor=false;
uint32_t nextScan=0, scanDeadline=0;
uint8_t wifiScanRetries=0;
int nextRadio=1, threshold=-55, strongest=-127, nearCount=0;
String history[24]; int historyCount=0;
int signalLog[24], signalCount=0;
int lastWifi=0,lastBle=0;
int scanKind=0;
struct Contact { String id; int rssi; uint32_t seen; int kind; String label; };
Contact contacts[80];
const uint32_t CONTACT_TTL=12000;
void updateContact(const String& id,int rssi,int kind,const String& label="") {
  int slot=-1;
  for(int i=0;i<80;i++) {
    if(contacts[i].kind==kind && contacts[i].id==id){slot=i;break;}
    if(slot<0 && contacts[i].kind==0)slot=i;
  }
  if(slot<0){slot=0;for(int i=1;i<80;i++)if((uint32_t)(millis()-contacts[i].seen)>(uint32_t)(millis()-contacts[slot].seen))slot=i;}
  bool changed=contacts[slot].kind!=kind || contacts[slot].id!=id || contacts[slot].rssi!=rssi;
  String shown=label.length()?label:(contacts[slot].id==id?contacts[slot].label:id);
  if(shown!=contacts[slot].label)changed=true;
  contacts[slot]={id,rssi,millis(),kind,shown};if(changed)dirty=true;
}
// BLE callbacks run on another task: transfer plain data, never draw there.
String bleHint(const NimBLEAdvertisedDevice* d);
String safeText(String s);
void recordEvent(String text);
struct BleUpdate { char id[20]; char name[64]; int rssi; };
QueueHandle_t bleUpdates=nullptr;
class LiveBleCallbacks : public NimBLEScanCallbacks {
  void onResult(const NimBLEAdvertisedDevice* d) override {
    BleUpdate u{};
    snprintf(u.id,sizeof(u.id),"%s",d->getAddress().toString().c_str());
    snprintf(u.name,sizeof(u.name),"%s",(bleHint(d)+String(d->haveName()?d->getName().c_str():u.id)).c_str());
    u.rssi=d->getRSSI();
    if(bleUpdates)xQueueSend(bleUpdates,&u,0);
  }
} liveBleCallbacks;
void receiveBleUpdates() {
  BleUpdate u;
  for(int budget=0;budget<64 && bleUpdates && xQueueReceive(bleUpdates,&u,0)==pdTRUE;budget++) {
    if(scanKind==2) {
      bool known=false;
      for(auto &c:contacts)if(c.kind==2 && c.id==u.id){known=true;break;}
      updateContact(String(u.id),u.rssi,2,safeText(String(u.name)));
      if(!known && String(u.name).indexOf('?')>=0)recordEvent(safeText(String(u.name)));
    }
  }
}
void liveSummary() {
  static uint32_t at=0;
  if(millis()-at<300)return;
  at=millis();
  if(view!=0 && view!=1 && view!=2)return;
  int best=-127,near=0,n=0;
  String fresh[40];
  for(auto &c:contacts)if(c.kind) {
    best=max(best,c.rssi);if(c.rssi>=threshold)near++;
    if(n<40 && c.kind==view)fresh[n++]=String(c.rssi)+"dBm "+c.label;
  }
  if(strongest!=best || nearCount!=near){strongest=best;nearCount=near;dirty=true;}
  if(view==1 || view==2){
    if(count!=n)dirty=true;
    for(int i=0;i<n;i++){if(rows[i]!=fresh[i])dirty=true;rows[i]=fresh[i];}
    count=n;page=min(page,max(0,(count-1)/6));
  }
}
void clearRadio(int kind) {
  for(auto &c:contacts)if(c.kind==kind)c.kind=0;
}
void expireContacts() {
  int w=0,b=0;
  for(auto &c:contacts) {
    if(c.kind && millis()-c.seen>CONTACT_TTL){c.kind=0;dirty=true;}
    if(c.kind==1)w++;else if(c.kind==2)b++;
  }
  if(w!=lastWifi || b!=lastBle){lastWifi=w;lastBle=b;dirty=true;}
}
void radarContacts() {
  for(auto &c:contacts)if(c.kind) {
    uint32_t hash=2166136261u;
    for(size_t i=0;i<c.id.length();i++)hash=(hash^(uint8_t)c.id[i])*16777619u;
    float angle=(hash%360)*0.0174532925f;
    int radius=constrain((-c.rssi-25)*42/75,8,44);
    int x=63+int(cosf(angle)*radius),y=103+int(sinf(angle)*radius);
    // Use the UI palette for both framebuffer depths (4-bit uses indices).
    uint16_t color=c.kind==1?uiColor[10]:AMBER;
    if(millis()-c.seen>8000)canvas->drawCircle(x,y,3,color);
    else canvas->fillCircle(x,y,3,color);
  }
}
void recordEvent(String text) {
  for(int i=min(historyCount,23);i>0;--i)history[i]=history[i-1];
  history[0]=String(millis()/1000)+"s "+text;
  if(historyCount<24)historyCount++;
  queueLog(history[0]);
}
void finishScan(const String& label) {
  expireContacts();
  if(strongest>-127){if(signalCount==24){for(int i=1;i<24;i++)signalLog[i-1]=signalLog[i];signalCount--;}signalLog[signalCount++]=strongest;}
  recordEvent(label+" "+String(count)+" / cerca "+String(nearCount));
  if(nearCount>0)status="AVISO: "+String(nearCount)+" senal fuerte";
  if(monitor){nextRadio=scanKind==1?2:1;nextScan=millis()+150;}
}


void add(String s) { if(count<40) rows[count++]=s; }
String safeText(String s) {
  for(size_t i=0;i<s.length();i++) if((uint8_t)s[i]<32 || (uint8_t)s[i]>126) s.setCharAt(i,'?');
  return s;
}
void home() { view=6; page=0; count=0; dirty=true; }
#include "languages.h"
void text(int x,int y,const String& value,uint16_t color=uiColor[5],int size=1){
  String localized=translateUI(value);
  if(uiLanguage==2)canvas->setFont(&fonts::efontCN_10);else canvas->setFont(&fonts::Font0);
  canvas->setTextSize(size);canvas->setTextColor(color);canvas->setCursor(x,y);canvas->print(localized);
}
void button(int x,int y,int w,const char* label,const char* detail) {
  canvas->fillRoundRect(x,y,w,44,5,PANEL);canvas->drawRoundRect(x,y,w,44,5,GRID);
  canvas->drawFastHLine(x+10,y+1,w-20,ACCENT);
  text(x+10,y+8,label,ACCENT);text(x+10,y+26,detail,uiColor[6]);
}
void signalBar(int x,int y,int w) {
  int power=strongest==-127?0:constrain((strongest+100)*100/70,0,100);
  for(int i=0;i<20;i++)canvas->fillRect(x+i*(w/20),y,w/20-2,8,i*5<power?(power>65?AMBER:ACCENT):GRID);
}
void trend(int x,int y,int w,int h) {
  for(int j=0;j<3;j++)canvas->drawFastHLine(x,y+j*h/2,w,GRID);
  for(int i=1;i<signalCount;i++) {
    int y1=y+h-constrain(signalLog[i-1]+100,0,80)*h/80;
    int y2=y+h-constrain(signalLog[i]+100,0,80)*h/80;
    canvas->drawLine(x+(i-1)*w/23,y1,x+i*w/23,y2,ACCENT);
  }
}
// Original v6.1: IP5306 I2C, SDA33/SCL22. Read only; never change power registers.
int batteryPercent=-1;
uint32_t batteryReadAt=0;
void serviceBattery(){
  if(batteryReadAt && millis()-batteryReadAt<3000)return;
  batteryReadAt=millis();int next=-1;
  Wire.beginTransmission(0x75);Wire.write(0x78);
  if(Wire.endTransmission(false)==0 && Wire.requestFrom((uint8_t)0x75,(uint8_t)1)==1){
    switch(Wire.read()&0xF0){case 0xE0:next=25;break;case 0xC0:next=50;break;case 0x80:next=75;break;case 0:next=100;break;case 0xF0:next=0;break;}
  }
  if(next!=batteryPercent){batteryPercent=next;dirty=true;}
}
void drawBattery(int x,int y){
  uint16_t color=batteryPercent<0?uiColor[6]:batteryPercent<=25?AMBER:ACCENT;
  canvas->drawRect(x,y,16,9,color);canvas->fillRect(x+16,y+3,2,3,color);
  if(batteryPercent>0)canvas->fillRect(x+2,y+2,max(1,batteryPercent*12/100),5,color);
  text(x+21,y+1,batteryPercent<0?"--%":String(batteryPercent)+"%",color);
}
// Compare composed strips before sending them: never clear the physical LCD
// between frames. Only changed 10-pixel-high strips cross the shared SPI bus.
bool forceFrame=true;
void presentFrame(){
  static uint64_t hashes[32]={};
  static int lastView=-1;
  const uint8_t* pixels=static_cast<const uint8_t*>(hudBuffer.getBuffer());
  if(!pixels)return;
  const int stride=120; // 240 pixels at 4 bits, fixed in setup.
  for(int strip=0;strip<32;strip++){
    uint64_t hash=14695981039346656037ULL;
    for(int i=0;i<stride*10;i++){hash^=pixels[strip*stride*10+i];hash*=1099511628211ULL;}
    if(forceFrame || lastView!=view || hashes[strip]!=hash){
      lcd.setClipRect(0,strip*10,240,10);hudBuffer.pushSprite(0,0);
      hashes[strip]=hash;
    }
  }
  lcd.clearClipRect();lastView=view;forceFrame=false;
}
#include "dashboard.h"
// Live inspector uses the same expiring observations as the radar.
int contactPage=0, contactFilter=0, selectedContact=-1;
String selectedID;
int liveOrder[80],liveCount=0;
void sortLiveContacts(){
  liveCount=0;
  for(int i=0;i<80;i++)if(contacts[i].kind && (!contactFilter || contacts[i].kind==contactFilter)){
    int j=liveCount++;
    while(j>0 && contacts[liveOrder[j-1]].rssi<contacts[i].rssi){liveOrder[j]=liveOrder[j-1];j--;}
    liveOrder[j]=i;
  }
  contactPage=min(contactPage,max(0,(liveCount-1)/4));
}
void drawLiveContacts(){
  dirty=false;sortLiveContacts();
  canvas=fullBuffered?static_cast<lgfx::LGFXBase*>(&hudBuffer):static_cast<lgfx::LGFXBase*>(&lcd);
  canvas->startWrite();canvas->fillScreen(BG);
  text(10,10,"CONTACTOS",ACCENT,2);
  text(10,34,"RSSI no equivale a distancia",uiColor[6]);
  const char* filters[]={"TODOS","WI-FI","BLE"};
  for(int i=0;i<3;i++){
    canvas->fillRoundRect(8+i*77,51,70,25,4,contactFilter==i?GRID:PANEL);
    text(16+i*77,60,filters[i],contactFilter==i?ACCENT:uiColor[6]);
  }
  if(selectedContact>=0){
    Contact &c=contacts[selectedContact];
    bool alive=c.kind && c.id==selectedID;
    text(10,91,alive?"OBSERVACION RECIENTE":"CONTACTO CADUCADO",alive?ACCENT:AMBER);
    text(10,115,selectedID,uiColor[5]);
    if(alive){
      text(10,139,c.label.substring(0,35),uiColor[5]);
      text(10,165,String(c.rssi)+" dBm",ACCENT,2);
      text(10,193,"Hace "+String((millis()-c.seen)/1000)+" s / TTL 12 s",uiColor[6]);
      text(10,216,c.kind==1?"Red Wi-Fi de 2.4 GHz":"Anuncio Bluetooth LE",uiColor[6]);
    }else text(10,151,"Sin recepcion reciente",uiColor[6]);
    text(10,247,"Toca el centro para volver",ACCENT);
  }else{
    for(int n=0;n<4;n++){
      int k=contactPage*4+n;if(k>=liveCount)break;
      Contact &c=contacts[liveOrder[k]];int y=84+n*44;
      canvas->fillRoundRect(8,y,224,40,4,PANEL);
      text(14,y+5,c.label.substring(0,34),uiColor[5]);
      text(14,y+22,String(c.kind==1?"WIFI ":"BLE  ")+String(c.rssi)+" dBm",c.kind==1?ACCENT:AMBER);
      text(169,y+22,String((millis()-c.seen)/1000)+"s",uiColor[6]);
      int w=constrain(c.rssi+100,0,75)*214/75;
      if(w)canvas->fillRect(13,y+36,w,2,c.kind==1?ACCENT:AMBER);
    }
    if(!liveCount){text(15,113,"Sin contactos recientes",AMBER);text(15,137,"Activa Monitor en el radar",uiColor[6]);}
    text(10,267,String(liveCount)+" activos / "+String(contactPage+1)+"/"+String(max(1,(liveCount+3)/4)),uiColor[6]);
  }
  canvas->fillRect(0,285,240,35,PANEL);
  text(12,299,"RADAR",ACCENT);text(89,299,"PAGINA",ACCENT);text(171,299,"INICIO",ACCENT);
  canvas->endWrite();if(fullBuffered)presentFrame();
}
void tapLiveContacts(int x,int y){
  if(y>=285){if(x<77)view=0;else if(x<157){selectedContact=-1;contactPage=(contactPage+1)%max(1,(liveCount+3)/4);}else home();}
  else if(y>=51 && y<77){contactFilter=min(2,x/77);contactPage=0;selectedContact=-1;}
  else if(selectedContact>=0)selectedContact=-1;
  else if(y>=84 && y<260){int k=contactPage*4+(y-84)/44;if(k<liveCount){selectedContact=liveOrder[k];selectedID=contacts[selectedContact].id;}}
  dirty=true;
}
void draw() {
  if(view==12){drawLiveContacts();return;}
  if(view>=6){drawDashboard();return;}
  static int previousView=-1;
  static bool previousMonitor=false;
  bool full=previousView!=view || previousMonitor!=monitor;
  previousView=view;previousMonitor=monitor;
  dirty=false;
  canvas=(view==0 && hudBuffered)?static_cast<lgfx::LGFXBase*>(&hudBuffer):static_cast<lgfx::LGFXBase*>(&lcd);
  if(fullBuffered)canvas=&hudBuffer;
  canvas->startWrite();canvas->fillScreen(BG);
  text(10,8,"SENTINEL",uiColor[5],2);
  drawBattery(123,13);
  text(11,30,"M61 / PASIVO",uiColor[7]);
  text(167,30,sdOK?"SD OK":sdState=="Error de escritura"?"SD ERROR":"SIN SD",sdOK?ACCENT:AMBER);
  canvas->fillRoundRect(185,8,46,20,6,monitor?uiColor[8]:PANEL);
  text(191,15,monitor?"AUTO":"IDLE",monitor?ACCENT:uiColor[6]);
  canvas->drawFastHLine(10,45,220,GRID);
  if(view==0) {
    // Same touch regions and buffered rendering; no animated full-screen sweep.
    canvas->fillRoundRect(8,51,106,101,8,PANEL);
    for(int r=18;r<=45;r+=14)canvas->drawCircle(63,103,r,GRID);
    canvas->drawFastHLine(18,103,90,GRID);canvas->drawFastVLine(63,58,90,GRID);
    canvas->fillCircle(63,103,2,ACCENT);
    radarContacts();
    canvas->fillRoundRect(122,51,109,45,6,PANEL);
    canvas->fillRoundRect(122,100,109,45,6,PANEL);
    text(130,58,"WI-FI",ACCENT);text(190,62,String(lastWifi),uiColor[5],2);
    text(130,75,"2.4 GHz",uiColor[7]);
    text(130,107,"BLE",AMBER);text(190,111,String(lastBle),uiColor[5],2);
    text(130,124,"Pasivo",uiColor[7]);
    text(12,157,gpsHasFix()?"GPS FIX >":gpsSeen?"GPS BUSCA >":"GPS -- >",gpsHasFix()?ACCENT:uiColor[7]);
    text(125,157,strongest==-127?"RSSI --":String(strongest)+"dBm C:"+String(nearCount),nearCount?AMBER:uiColor[6]);
    if(hudBuffered && !fullBuffered){
      canvas->endWrite();hudBuffer.pushSprite(0,0);canvas=&lcd;
      if(!full)return;
      lcd.startWrite();lcd.fillRect(0,168,240,152,BG);
    }
    button(10,177,106,"WI-FI","Explorar");button(124,177,106,"BLUETOOTH","Explorar BLE");
    button(10,228,106,"MONITOR",monitor?"AUTO > IDLE":"IDLE > AUTO");button(124,228,106,"HISTORIAL","Ajustar umbral");
    canvas->fillRoundRect(10,279,220,25,4,PANEL);
    canvas->drawFastVLine(75,280,23,GRID);canvas->drawFastVLine(151,280,23,GRID);
    text(28,288,"GPS",ACCENT);text(86,288,"SISTEMA",ACCENT);
    text(169,288,"CALIBRAR",ACCENT);
    text(10,311,monitor?(scanKind==1?"AUTO / ESCUCHANDO WI-FI":"AUTO / ESCUCHANDO BLE"):"IDLE / TOCA MONITOR PARA INICIAR",uiColor[7]);
  } else {
    text(10,53,status.substring(0,36),scanning?AMBER:ACCENT);
    canvas->fillRoundRect(10,68,220,57,5,PANEL);
    text(18,75,view==1?"WI-FI":view==2?"BLE":view==4?"GPS / SD":"REGISTRO",ACCENT);
    if(view==4) {
      bool valid=gpsHasFix();
      text(18,92,valid&&gps.speed.isValid()&&gps.speed.age()<5000?String(gps.speed.mph(),1)+" mph":"-- mph",AMBER,2);
      text(144,75,"SAT USADOS",uiColor[6]);
      text(151,96,gps.satellites.isValid()&&gps.satellites.age()<5000?String(gps.satellites.value()):"--",ACCENT,2);
    } else {
      text(18,92,strongest==-127?"-- dBm":String(strongest)+" dBm",AMBER,2);
      text(144,75,"RSSI / HIST.",uiColor[6]);trend(145,91,75,25);
    }
    for(int i=0;i<6;i++) {int k=page*6+i;if(k>=count)break;
      int y=134+i*23;
      if(i%2==0)canvas->fillRect(7,y-3,226,21,PANEL);
      text(11,y,rows[k].substring(0,36),uiColor[5]);
    }
    text(10,271,String(page+1)+"/"+String(max(1,(count+5)/6))+"   "+String(count)+" entradas",uiColor[6]);
    canvas->fillRect(0,282,240,38,PANEL);canvas->drawFastHLine(0,282,240,ACCENT);
    canvas->drawFastVLine(77,283,37,GRID);canvas->drawFastVLine(157,283,37,GRID);
    text(13,297,"INICIO",ACCENT);text(88,297,view==5?"UMBRAL":view==3?"SD/GPS":"PAGINA",ACCENT);text(163,297,view==3?"CALIBRAR":"GUARDAR",ACCENT);
  }
  canvas->endWrite();
  if(fullBuffered)presentFrame();
}
// Signature hints, not proof of identity or tracking. No active connections.
String wifiHint(String name) {
  name.toLowerCase();
  if(name=="flock" || name.startsWith("flock-"))return "FLOCK? ";
  if(name.startsWith("ab2-")||name.startsWith("ab3-")||name.startsWith("ab4-")||name.startsWith("axon-"))return "AXON? ";
  if(name.startsWith("ring-")||name.startsWith("ring_"))return "RING NOMBRE? ";
  if(name.startsWith("dji-")||name.startsWith("dji_")||name.startsWith("tello-"))return "DRON NOMBRE? ";
  if(name.indexOf("camera")>=0||name.startsWith("ipcam")||name.startsWith("wyze")||name.startsWith("gopro")||name.startsWith("insta360"))return "CAMARA NOMBRE? ";
  if(name.startsWith("pineapple_")||name=="pwned")return "PENTEST? ";
  return "";
}
String bleHint(const NimBLEAdvertisedDevice* d) {
  if(d->isAdvertisingService(NimBLEUUID((uint16_t)0xFD5A)))return "SMARTTAG? ";
  if(d->isAdvertisingService(NimBLEUUID((uint16_t)0xFEED))||d->isAdvertisingService(NimBLEUUID((uint16_t)0xFEEC)))return "TILE? ";
  if(d->isAdvertisingService(NimBLEUUID((uint16_t)0xFD5F)))return "GAFAS? ";
  // FEAA also belongs to Eddystone; the frame type disambiguates the hint.
  auto google=d->getServiceData(NimBLEUUID((uint16_t)0xFEAA));
  if(google.size()>=2 && ((uint8_t)google[0]==0x40 || (uint8_t)google[0]==0x41))return "GOOGLE TAG? ";
  if(!google.empty())return "EDDYSTONE? ";
  auto rid=d->getServiceData(NimBLEUUID((uint16_t)0xFFFA));
  if(rid.size()>=27 && (uint8_t)rid[0]==0x0D)return "DRON REMOTE-ID? ";
  for(uint8_t i=0;i<d->getManufacturerDataCount();i++) {
    auto m=d->getManufacturerData(i);
    if(m.size()>=4 && (uint8_t)m[0]==0x4c && (uint8_t)m[1]==0x00 && (uint8_t)m[2]==0x12 && (uint8_t)m[3]==0x19)
      return "AIRTAG/FINDMY? ";
  }
  String name=d->haveName()?String(d->getName().c_str()):"";name.toLowerCase();
  if(name.indexOf("flock")>=0||name.indexOf("penguin")>=0||name.indexOf("pigvision")>=0)return "FLOCK NOMBRE? ";
  if(name.indexOf("camera")>=0||name.startsWith("gopro")||name.startsWith("insta360")||name.startsWith("ring"))return "CAMARA NOMBRE? ";
  if(name.indexOf("flipper")>=0)return "FLIPPER NOMBRE? ";
  return "";
}
void beginWifi(bool retry=false) {
  if(ble && ble->isScanning())ble->stop();
  if(!retry)wifiScanRetries=0;
  WiFi.setAutoReconnect(autoJoin);
  bool ready=WiFi.getMode()==WIFI_STA || WiFi.mode(WIFI_STA);
  count=0;page=0;view=1;scanKind=1; strongest=-127;nearCount=0;scanDeadline=millis()+20000;
  status="Wi-Fi: explorando...";scanning=true;dirty=true;
  // Passive scan: no probe requests; nothing is associated or connected.
  int result=ready?WiFi.scanNetworks(true,true,true,150):WIFI_SCAN_FAILED;
  Serial.printf("Wi-Fi scan inicio: %d, modo %d, RAM %u\n",result,(int)WiFi.getMode(),ESP.getFreeHeap());
  // The loop handles immediate start failures as well as asynchronous failures.
}
void beginBle() {
  esp_wifi_scan_stop();WiFi.scanDelete();count=0;page=0;view=2;scanKind=2;
  strongest=-127;nearCount=0;scanDeadline=millis()+20000;
  scanning=false;status="Preparando Bluetooth...";
  if(bleUpdates)xQueueReset(bleUpdates);dirty=true;
  if(bleInitState.load()==0 || bleInitState.load()==-1) {
    bleInitState.store(1);
    if(xTaskCreate(initBleTask,"ble-init",8192,nullptr,1,nullptr)!=pdPASS)
      bleInitState.store(-1);
  }
  bleSeconds=-1;
}
void serviceBle() {
  if(scanKind!=2)return;
  int state=bleInitState.load();
  if(!scanning && status=="Preparando Bluetooth...") {
    if(state==-1){status="Error al iniciar Bluetooth";dirty=true;return;}
    if(state!=2){if((int32_t)(millis()-scanDeadline)>0){status="Bluetooth: tiempo agotado";monitor=false;dirty=true;}return;}
    if(!ble)ble=NimBLEDevice::getScan();
    if(!ble){status="Bluetooth no disponible";monitor=false;dirty=true;return;}
    ble->setActiveScan(false);ble->setMaxResults(40);
    ble->setScanCallbacks(&liveBleCallbacks,true);ble->setDuplicateFilter(0);
    ble->setInterval(100);ble->setWindow(100);
    ble->clearResults();scanning=ble->start(3000);
    Serial.printf("BLE scan: %s, RAM %u\n",scanning?"OK":"ERROR",ESP.getFreeHeap());
    if(!scanning)monitor=false;
    bleStarted=millis();
    status=scanning?"BLE: en directo":"Error al iniciar escaneo";dirty=true;
  }

}
void stopMonitor() {
  monitor=false;nextScan=0;
  if(ble && ble->isScanning())ble->stop();
  if(scanKind==1){esp_wifi_scan_stop();WiFi.scanDelete();}
  scanning=false;scanKind=0;
  if(bleUpdates)xQueueReset(bleUpdates);
  status="IDLE / exploracion detenida";dirty=true;
}
void toggleMonitor() {
  if(monitor){stopMonitor();return;}
  stopMonitor();monitor=true;beginWifi();view=0;dirty=true;
}
void systemPage() {
  view=3;count=0;page=0;status="Sistema";
  add("Chip: "+String(ESP.getChipModel()));
  add("Flash: "+String(ESP.getFlashChipSize()/1024)+" KiB");
  add("RAM libre: "+String(ESP.getFreeHeap()/1024)+" KiB");
  add("PSRAM: no requerida");add(sdOK?"microSD: montada":"microSD: no montada");
  if(sdOK)add("SD total: "+String((uint32_t)(SD.totalBytes()/1048576))+" MiB");
  add("SD: "+sdState);
  add(exportingCities?"Ciudades SD: "+String(cityExport)+"/"+String(cityCount):"Ciudades: flash / copia en SD");
  add("Log automatico: cada 5 segundos");
  add("RAM pendiente: "+String(logSize)+" / 32");
  add("Descartados RAM: "+String(logDropped));
  add("GPS: abre con boton SD/GPS");
  add("Tactil: boton CALIBRAR abajo");
  add(bleInitState.load()==2?"BLE: inicializado":"BLE: no inicializado");
  add("Aviso: RSSI >= "+String(threshold)+" dBm");
  add("? = indicio, no identidad");add("Senal fuerte NO es amenaza");add("Wi-Fi solo banda 2.4 GHz");
  add("BLE no es Bluetooth clasico");dirty=true;
}
void save() {
  if(scanning){status="Espera al fin del escaneo";dirty=true;return;}
  if(!sdOK){status="Sin microSD FAT32";dirty=true;return;}
  File f=SD.open("/div-sentinel.txt",FILE_APPEND);
  if(!f){storageFailure();status="Error al abrir archivo";dirty=true;return;}
  f.printf("\nDIV Sentinel / arranque +%lu ms / vista %d\n",(unsigned long)millis(),view);
  bool ok=true;
  for(int i=0;i<count;i++)if(!f.println(rows[i]))ok=false;
  if(gps.location.isValid() && gps.location.age()<5000)
    f.printf("GPS: %.6f,%.6f\n",gps.location.lat(),gps.location.lng());
  f.flush();f.close();
  if(!ok){storageFailure();status="Error escribiendo microSD";dirty=true;return;}
  status="Guardado en microSD";dirty=true;
}
void mountSD() {
  // The official v6.1 uses CS14 on the same VSPI pins as the display.
  // All SD access is on the main task, outside touch/display transactions.
  if(displayReady){lcd.waitDisplay();lcd.endTransaction();}
  digitalWrite(17,HIGH);digitalWrite(21,HIGH);
  SD.end();sdOK=false;
  const uint32_t rates[]={400000,1000000,4000000};
  for(uint32_t hz:rates) {
    digitalWrite(14,HIGH);delay(20);
    if(SD.begin(14,storageBus,hz) && SD.cardType()!=CARD_NONE){
      sdOK=true;Serial.printf("microSD: montada a %lu Hz\n",(unsigned long)hz);break;
    }
    SD.end();
  }
  sdState=sdOK?"Lista / log automatico":"Sin respuesta / revisar tarjeta";
  if(sdOK) {
    if(!SD.exists("/sentinel" ) && !SD.mkdir("/sentinel")){storageFailure();return;}
    // Partial exports never replace a completed database.
    exportingCities=!SD.exists("/sentinel/cities-v1.csv");cityExport=0;
    if(exportingCities)SD.remove("/sentinel/cities-v1.tmp");
    queueLog(String(millis()/1000)+"s SD montada / inicio de registro");
  }
  Serial.printf("microSD: %s\n",sdState.c_str());dirty=true;
}
void serviceStorage() {
  static uint32_t cityTick=0,gpsTick=0;
  if(!sdOK)return;
  // Incremental export keeps GPS reception and touch servicing responsive.
  if(exportingCities && millis()-cityTick>=100) {
    cityTick=millis();
    File f=SD.open("/sentinel/cities-v1.tmp",FILE_APPEND);
    if(!f){storageFailure();return;}
    bool ok=true;
    if(cityExport==0)ok=f.println("latitude,longitude,name")>0;
    for(int n=0;n<16 && cityExport<cityCount && ok;n++,cityExport++) {
      ok=f.printf("%.5f,%.5f,\"%s\"\n",cities[cityExport].lat,cities[cityExport].lon,cities[cityExport].name)>0;
    }
    f.flush();f.close();
    if(!ok){storageFailure();return;}
    if(cityExport==cityCount){
      if(!SD.rename("/sentinel/cities-v1.tmp","/sentinel/cities-v1.csv")){storageFailure();return;}
      exportingCities=false;Serial.println("microSD: base de ciudades guardada");
    }
  }
  if(gpsHasFix() && millis()-gpsTick>=60000) {
    gpsTick=millis();
    queueLog(String(millis()/1000)+"s GPS "+String(gps.location.lat(),6)+","+String(gps.location.lng(),6)+" / "+nearbyCity());
  }
  if(logSize && millis()-lastStorage>=5000) {
    lastStorage=millis();
    File f=SD.open("/sentinel/events.log",FILE_APPEND);
    if(!f){storageFailure();return;}
    bool ok=true;
    if(logDropped)ok=f.printf("RAM: %lu entradas anteriores descartadas\n",(unsigned long)logDropped)>0;
    int written=0;
    while(ok && written<logSize && written<8){ok=f.println(logPending[(logHead+written)%32])>0;if(ok)written++;}
    f.flush();f.close();
    logHead=(logHead+written)%32;logSize-=written;
    if(!ok){storageFailure();return;}
    logDropped=0;
    Serial.printf("microSD: log guardado (%d entradas)\n",written);
  }
}
void gpsPage() {
  if(view!=4)page=0;
  view=4;
  String fresh[12];int n=0;
  bool fix=gpsHasFix() && millis()-gpsValidAt<5000;
  String newStatus=fix?"GPS: FIX / posicion valida":gpsSeen && millis()-gpsValidAt<5000?"GPS: sin FIX / buscando":gpsSeen?"GPS: datos caducados":gpsBytes && millis()-gpsLastByte<5000?"GPS: RX sin NMEA valido":"GPS: sin datos en RX4";
  fresh[n++]="Lat: "+(fix?String(gps.location.lat(),6):String("--"));
  fresh[n++]="Lon: "+(fix?String(gps.location.lng(),6):String("--"));
  fresh[n++]=fix&&gps.altitude.isValid()&&gps.altitude.age()<5000?"Altura: "+String(gps.altitude.meters(),1)+" m":"Altura: -- m";
  fresh[n++]="FIX: "+String(fix?"SI":"NO")+"  SAT: "+(gps.satellites.isValid()&&gps.satellites.age()<5000?String(gps.satellites.value()):String("--"));
  fresh[n++]=nearbyCity();
  fresh[n++]=!gpsSeen?"RX: "+String(gps.charsProcessed())+" / sin NMEA":millis()-gpsValidAt>5000?"Receptor: datos antiguos":fix?"Receptor: posicion actual":"Receptor: buscando posicion";
  fresh[n++]="UART RX4 / "+String(gpsBauds[gpsBaudIndex])+" baudios";
  fresh[n++]="Recibidos: "+String(gpsBytes)+" bytes";
  fresh[n++]="NMEA OK: "+String(gps.passedChecksum());
  fresh[n++]="Checksum fallidos: "+String(gps.failedChecksum());
  fresh[n++]="HDOP: "+(gps.hdop.isValid()&&gps.hdop.age()<5000?String(gps.hdop.hdop(),1):String("--"));
  fresh[n++]="Cielo despejado; no mover cables";
  bool changed=status!=newStatus || count!=n;
  for(int i=0;i<n;i++){if(rows[i]!=fresh[i])changed=true;rows[i]=fresh[i];}
  count=n;status=newStatus;if(changed)dirty=true;
}
void serviceGPS() {
  for(int budget=0;budget<2048 && gpsPort.available();budget++){
    char c=(char)gpsPort.read();gpsLastByte=millis();gpsBytes++;
    if(gps.encode(c)){gpsSeen=true;gpsValidAt=millis();}
  }
  static uint32_t lastChecksum=0;
  if(gps.passedChecksum()!=lastChecksum){lastChecksum=gps.passedChecksum();gpsSeen=true;gpsValidAt=millis();}
  if(millis()-gpsProbeAt>6000 && (!gpsSeen || millis()-gpsValidAt>15000)) {
    gpsBaudIndex=(gpsBaudIndex+1)%6;gpsPort.end();
    gpsPort.begin(gpsBauds[gpsBaudIndex],SERIAL_8N1,4,-1);gpsProbeAt=millis();
  }
  static bool priorFix=false;
  bool fixNow=gpsHasFix();
  if(fixNow!=priorFix){priorFix=fixNow;if(view==0)dirty=true;}
  static uint32_t tick=0;
  if(view==4 && millis()-tick>=1000){tick=millis();gpsPage();}
}
// Run directly on the LCD, never on the HUD sprite. Keep the current rotation.
void recalibrateTouch() {
  forceFrame=true;
  stopMonitor();
  lcd.waitDisplay();lcd.endTransaction();
  uint16_t x,y;
  while(lcd.getTouch(&x,&y)){serviceGPS();delay(15);}
  lcd.fillScreen(uint16_t(0x0001));lcd.setTextSize(1);lcd.setTextColor(uint16_t(0xFFFF));
  lcd.setCursor(16,110);lcd.println("CALIBRAR PANTALLA");
  lcd.setCursor(16,135);lcd.println("Toca el centro de cada marca.");
  lcd.setCursor(16,155);lcd.println("Suelta entre cada pulsacion.");
  delay(1600);
  uint16_t cal[8];lcd.calibrateTouch(cal,uint16_t(0xFFFF),uint16_t(0x0001),12);
  lcd.setTouchCalibrate(cal);
  Preferences p;p.begin("m61-touch",false);
  bool saved=p.putBytes("portrait2",cal,sizeof(cal))==sizeof(cal);p.end();
  while(lcd.getTouch(&x,&y)){serviceGPS();delay(15);}
  lastTap=millis();systemPage();
  status=saved?"Calibracion guardada":"Error guardando calibracion";dirty=true;
}
// Require a short stable contact and a real release; holding cannot repeat taps.
bool readTap(uint16_t &x,uint16_t &y) {
  static bool tracking=false,consumed=false;
  static uint32_t started=0,released=0;
  static uint16_t px=0,py=0;
  uint32_t now=millis();uint16_t tx,ty;
  if(!lcd.getTouch(&tx,&ty)) {
    if(!released)released=now;
    if(now-released>=70){tracking=false;consumed=false;}
    return false;
  }
  released=0;
  if(consumed)return false;
  if(!tracking || abs(int(tx)-int(px))>14 || abs(int(ty)-int(py))>14){
    tracking=true;started=now;px=tx;py=ty;return false;
  }
  px=(px+tx)/2;py=(py+ty)/2;
  if(now-started<45)return false;
  consumed=true;x=px;y=py;return true;
}
void setup() {
  Serial.begin(115200);
  bleUpdates=xQueueCreate(64,sizeof(BleUpdate));
  // Force-link Arduino BT support so initArduino retains controller memory.
  (void)btStarted();
  // All shared SPI devices deselected before display initialization.
  const int cs[]={17,21,14};
  for(int pin:cs){pinMode(pin,OUTPUT);digitalWrite(pin,HIGH);}
  pinMode(32,OUTPUT);digitalWrite(32,LOW);
  // Put the card into SPI mode BEFORE any TFT/touch traffic on the shared bus.
  storageBus.begin(18,19,23,14);mountSD();
  lcd.init();displayReady=true;lcd.setRotation(2);lcd.fillScreen(BG);
  // Official original Marauder backlight is active HIGH, after display init.
  digitalWrite(32,HIGH);
  Preferences prefs;
  prefs.begin("m61-touch",false);
  uint16_t cal[8];
  if(prefs.isKey("portrait2") && prefs.getBytesLength("portrait2")==sizeof(cal)) {
    prefs.getBytes("portrait2",cal,sizeof(cal));lcd.setTouchCalibrate(cal);
  } else {
    lcd.setTextSize(1);lcd.setTextColor(uiColor[5]);lcd.setCursor(10,130);
    lcd.println("Calibracion tactil");
    lcd.setCursor(10,150);lcd.println("Toca las marcas de las esquinas");
    delay(1800);
    lcd.calibrateTouch(cal,uint16_t(0xFFFF),uint16_t(0x0001),12);
    prefs.putBytes("portrait2",cal,sizeof(cal));
  }
  prefs.end();
  Preferences settings;settings.begin("sentinel",true);threshold=settings.getInt("threshold",-55);fahrenheit=settings.getBool("fahrenheit",false);hour12=settings.getBool("hour12",false);uiLanguage=constrain(settings.getUChar("language",0),0,5);settings.end();
  // Reserve radio memory BEFORE the optional framebuffer consumes a large block.
  WiFi.persistent(false);WiFi.setAutoReconnect(true);
  bool wifiReady=WiFi.mode(WIFI_STA);
  Serial.printf("Wi-Fi init: %s, RAM %u, bloque %u\n",wifiReady?"OK":"ERROR",ESP.getFreeHeap(),ESP.getMaxAllocHeap());
  loadNetwork();
  // Credentials live in NVS, independently of the SD card.
  // Reserve BLE resources before allocating the framebuffer too.
  bleInitState.store(1);
  if(xTaskCreate(initBleTask,"ble-init",8192,nullptr,1,nullptr)!=pdPASS)bleInitState.store(-1);
  gpsPort.setRxBufferSize(2048);
  gpsPort.begin(9600,SERIAL_8N1,4,-1); // Official v6.1 GPS TX feeds GPIO4; receive only.
  gpsProbeAt=millis();
  // BLE allocation must finish before optional display memory is allocated.
  uint32_t initWait=millis();
  while(bleInitState.load()==1 && millis()-initWait<10000){serviceGPS();delay(10);}
  // Eleven UI colours fit in a 16-colour frame, saving 38,400 bytes
  // versus the previous 8-bit allocation. Leave memory for BLE and TLS.
  hudBuffer.setColorDepth(4);
  fullBuffered=hudBuffer.createSprite(240,320)!=nullptr;
  if(fullBuffered){for(int i=0;i<11;i++){hudBuffer.setPaletteColor(i,uiColor[i]);uiColor[i]=i;}}
  hudBuffered=fullBuffered;
  Serial.printf("Pantalla: buffer completo %s\n",fullBuffered?"OK":"sin memoria");
  Wire.begin(33,22,100000);Wire.setTimeOut(25);serviceBattery();
  home();draw();
  Serial.println("Marauder Sentinel v6.1: listo; firmware de diagnostico, sin PSRAM obligatoria");
  home();
}
void loop() {
  serviceBattery();
  serviceGPS();
  serviceDashboard();
  serviceStorage();
  serviceBle();
  receiveBleUpdates();
  expireContacts();
  liveSummary();
  if(!scanning && nextScan && (int32_t)(millis()-nextScan)>=0) {
    nextScan=0;int oldView=view;if(nextRadio==1)beginWifi(wifiScanRetries>0);else beginBle();view=oldView;dirty=true;
  }
  if(scanning && scanKind==1 && (int32_t)(millis()-scanDeadline)>0){
    esp_wifi_scan_stop();WiFi.scanDelete();scanning=false;monitor=false;nextScan=0;status="Wi-Fi: tiempo agotado";dirty=true;
  }
  if(scanning && scanKind==1) {
    int n=WiFi.scanComplete();
    if(n>=0){count=0;for(int i=0;i<n && count<40;i++) {
      int r=WiFi.RSSI(i);updateContact(WiFi.BSSIDstr(i),r,1,wifiHint(WiFi.SSID(i))+safeText(WiFi.SSID(i).length()?WiFi.SSID(i):WiFi.BSSIDstr(i)));strongest=max(strongest,r);if(r>=threshold)nearCount++;
      String hint=wifiHint(WiFi.SSID(i));
      if(hint.length())recordEvent(hint+safeText(WiFi.SSID(i)));
      add(hint+String(r)+"dBm C"+String(WiFi.channel(i))+" "+safeText(WiFi.SSID(i)));
      }
      wifiScanRetries=0;status=String(n)+" redes Wi-Fi";scanning=false;WiFi.scanDelete();finishScan("Wi-Fi");if(!monitor){nextRadio=1;nextScan=millis()+150;}dirty=true;
    }else if(n==WIFI_SCAN_FAILED){
      scanning=false;WiFi.scanDelete();
      Serial.printf("Wi-Fi scan fallo: intento %u, RAM %u\n",wifiScanRetries+1,ESP.getFreeHeap());
      if(wifiScanRetries<2){
        ++wifiScanRetries;esp_wifi_scan_stop();
        nextRadio=1;nextScan=millis()+700;
        status="Wi-Fi: recuperando "+String(wifiScanRetries)+"/2";
      }else{monitor=false;nextScan=0;status="Wi-Fi: fallo tras 3 intentos";}
      dirty=true;
    }
  }
  if(scanning && scanKind==2 && !ble->isScanning()) {
    auto r=ble->getResults();count=0;
    for(int i=0;i<r.getCount() && count<40;i++){
      auto d=r.getDevice(i);String name=d->haveName()?String(d->getName().c_str()):String(d->getAddress().toString().c_str());
      int power=d->getRSSI();updateContact(String(d->getAddress().toString().c_str()),power,2,bleHint(d)+safeText(name));strongest=max(strongest,power);if(power>=threshold)nearCount++;
      String hint=bleHint(d);
      if(hint.length())recordEvent(hint+safeText(name));
      add(hint+String(power)+"dBm "+safeText(name));
    }
    status=String(r.getCount())+" dispositivos BLE";
    if(r.getCount()==0){add("No hay anuncios BLE cercanos");add("Activa un dispositivo BLE");add("y vuelve a explorar.");add("No detecta Bluetooth clasico.");}
    ble->clearResults();scanning=false;finishScan("BLE");if(!monitor){nextRadio=2;nextScan=millis()+150;}dirty=true;
  }
  uint16_t x=0,y=0;
  if(readTap(x,y) && millis()-lastTap>180) {
    lastTap=millis();
    if(view==12){tapLiveContacts(x,y);}
    else if(view>=6){tapDashboard(x,y);}
    else if(view==0){
      if(y<45 && x<180){stopMonitor();home();}
      else if(y<45 && x>=180){toggleMonitor();}
      else if(y>=51 && y<148){view=12;contactPage=0;selectedContact=-1;dirty=true;}
      else if(y>=150&&y<175 && x<124){stopMonitor();gpsPage();}
      else if(y>=177&&y<221){monitor=false;if(x<120)beginWifi();else beginBle();}
      else if(y>=228&&y<272){
        if(x<120){toggleMonitor();}
        else {view=5;count=0;page=0;status="Umbral "+String(threshold)+" dBm";
          for(int i=0;i<historyCount;i++)add(history[i]);
          if(!count)add("Aun no hay exploraciones");dirty=true;}
      }
      else if(y>=279&&y<306){stopMonitor();if(x<75)gpsPage();else if(x<152)systemPage();else recalibrateTouch();}
    } else if(y>=282){
      if(x<75){monitor=false;nextScan=0;
        if(scanning){if(scanKind==1){esp_wifi_scan_stop();WiFi.scanDelete();}if(scanKind==2&&ble)ble->stop();scanning=false;}scanKind=0;home();}
      else if(x<157){
        if(view==3){mountSD();gpsPage();}
        else if(view==5){threshold=threshold==-55?-65:threshold==-65?-75:-55;
          Preferences p;p.begin("sentinel",false);p.putInt("threshold",threshold);p.end();
          status="Umbral "+String(threshold)+" dBm";
        }else page=(page+1)%max(1,(count+5)/6);dirty=true;
      }else {if(view==3)recalibrateTouch();else {if(monitor){monitor=false;nextScan=0;}save();}}
    }
  }
  if(view==4 && dirty)gpsPage();
  static uint32_t contactTick=0;
  if(view==12 && millis()-contactTick>=1000){contactTick=millis();dirty=true;}
  static uint32_t lastDraw=0;
  static int drawnView=-1;
  if(dirty && (view!=drawnView || millis()-lastDraw>=((fullBuffered && (view==6 || view==11))?100:300))){
    draw();lastDraw=millis();drawnView=view;
  }
  delay(15);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik