This wireless anti-tracking detector combines an ESP32 microcontroller with a NEO-6M GPS module to monitor location data and detect tracking attempts. The project displays real-time GPS positioning on a built-in screen, shows a world map view of the device's location, and provides visual alerts when the International Space Station passes overhead—useful for understanding satellite tracking patterns and signal behavior.
The guide includes a complete parts list, wiring diagram for connecting the GPS module to the ESP32, step-by-step assembly and calibration instructions, and the full firmware needed to get the system running. Builders will learn how to configure GPS reception outdoors, set up the display interface, and interpret location signals for anti-tracking awareness.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Instalar y calibrar
Conecta el Marauder v6.1 por USB y pulsa Deploy. La primera vez toca las esquinas indicadas para calibrar la pantalla. La calibración queda guardada. El GPS externo es opcional para tu ubicación.
- Sustituye el firmware instalado. Incluye globo ISS y monitor BLE/Wi-Fi propios, no todos los menús del Marauder original.
2. Conectar desde la pantalla
Toca WIFI y Buscar. Selecciona tu red de 2,4 GHz. Introduce la clave en el teclado táctil; aA/1 cambia entre minúsculas, mayúsculas y símbolos. Espacio añade un espacio y Borrar quita el último carácter. Pulsa Conectar. Mas muestra otras redes y Calibrar repite la calibración. Se guardan nombre y clave para reconectar. Debajo de los botones, toca Unidades para alternar entre métrico (km y km/h) y estándar (millas y mph); la elección se conserva al reiniciar. Las coordenadas permanecen en grados y las frecuencias en MHz. Si la fuente ISS no entrega altitud o velocidad, se indica sin datos, sin inventar valores. Como alternativa sigue disponible la red ISS-Config, clave orbita2026, con página http://192.168.4.1 mientras no haya conexión.
- Las credenciales se guardan sin cifrar. La clave no se muestra en pantalla; configura en privado. La página alternativa usa HTTP local.
3. Ver la Tierra y las señales
ISS muestra océanos, continentes rellenos, zonas polares y sombreado decorativo de esfera. El marcador amarillo muestra la ISS, consultada cada cinco segundos con Internet. La pantalla utiliza ocho franjas de 4,8 kB, reservadas antes de iniciar los controladores, para no necesitar un bloque grande continuo y evitar borrar la pantalla durante cada actualización. Toca la cabecera ISS para abrir RADIO con frecuencias TX, RX y tono del repetidor ISS, APRS y Ham TV; toca la cabecera RADIO o la pestaña ISS para volver. Los datos de radio son una referencia ARISS del 4 de noviembre de 2026, no un estado consultado en directo. MONITOR alterna escucha BLE y escaneo pasivo de puntos de acceso Wi-Fi. Conserva hasta 160 identificadores en RAM al cambiar de pestaña; reiniciar borra ese historial. Un asterisco indica observaciones en tres ventanas de minuto durante dos minutos o más. El monitor y las consultas ISS se alternan por pestaña; no son simultáneos.
- Una señal repetida no confirma seguimiento. Las direcciones BLE pueden cambiar. No detecta receptores GPS ni redes móviles; Wi-Fi solo detecta puntos de acceso, no todos los dispositivos.
- El globo usa costas simplificadas y colores ilustrativos, no fotografía satelital ni iluminación solar real. El GPS local muestra tu posición con una recepción válida.
- Las frecuencias mostradas son informativas. El Marauder no recibe ni transmite VHF/UHF; se necesita una radio externa. Transmitir requiere la autorización correspondiente y el estado del repetidor puede cambiar.
- La API wheretheiss.at necesita Internet. La conexión HTTPS no verifica el certificado. No usar para navegación ni seguridad crítica.
- La consulta ISS se ejecuta en una tarea de red independiente y el escaneo BLE es asincrónico. La información ISS puede retrasarse si falla Internet, sin esperar esa consulta para leer los menús.
4. Conectar el GPS con la placa apagada
Receptor elegido: NEO-6M NMEA a 9600 baudios. TX del GPS a GPIO4 (RX del Marauder, recibe posiciones); RX del GPS a GPIO13 (TX del Marauder); GND a GND y VCC a 3V3. GPIO4 y GPIO13 se interpretan como RX y TX del Marauder. No conectes una salida de datos de 5 V.
- No utilices GPIO4 o GPIO13 para otra función a la vez. Apaga la placa antes de cablear.
- Usa un módulo con alimentación y señales compatibles con 3,3 V. No apliques 5 V a los GPIO.
5. Obtener posición al aire libre
Coloca la antena mirando al cielo. La primera posición puede tardar varios minutos. El punto verde indica tu posición y el amarillo la ISS; el globo se centra en la longitud de la ISS para mantenerla visible. Tu marcador solo aparece si está en el hemisferio visible. Las credenciales Wi-Fi se conservan al apagar y se reintenta la conexión automáticamente.
6. Aviso visual para intentar un contacto ISS
Con GPS válido y conexión a Internet, aparece un aviso en cualquier menú cuando la elevación calculada de la ISS alcanza 10 grados sobre tu horizonte. Toca el aviso para abrir RADIO y reconocerlo; se rearma cuando la elevación baja de 5 grados. Usa la posición actual consultada cada cinco segundos, no una predicción de próximos pasos. Deja abierta ISS, RADIO o WI-FI para recibir actualizaciones; el monitor RF suspende las consultas. El aviso se oculta si los datos tienen 20 segundos o más, falta GPS o se pierde Wi-Fi. No incluye sonido ni controla tus radios.
- La elevación es una estimación geométrica con Tierra esférica; no contempla edificios, montañas ni refracción. No asegura cobertura, repetidor activo ni contacto. Utiliza una radio y antena adecuadas y transmite solo con autorización.
- Si falla la API no habrá avisos fiables. No predice hora del próximo paso ni avisa con la placa apagada.
Review all connections
1. Connections between "gps" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include "worldmap.h"
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <esp_heap_caps.h>
#include <TFT_eSPI.h>
#include <math.h>
#include <new>
#include <utility>
#include <time.h>
#include <sys/time.h>
#include <NimBLEDevice.h>
#include <TinyGPSPlus.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
TFT_eSPI tft;
// Eight independently allocated strips avoid requiring one contiguous 38 kB block.
// Each strip is fully rendered before transfer; no clearing the physical display.
class StripFrame {
TFT_eSprite* strips[8]={};
uint8_t* pixels[8]={};
uint64_t sentHash[8]={};
bool sent[8]={};
public:
void setColorDepth(int depth) { for(int i=0;i<8;i++){if(!strips[i])strips[i]=new TFT_eSprite(&tft);strips[i]->setColorDepth(depth);} }
void* createSprite(int,int) {
for(int i=0;i<8;i++){pixels[i]=static_cast<uint8_t*>(strips[i]->createSprite(240,40));if(!pixels[i])return nullptr;}
return this;
}
void createPalette(const uint16_t* p,int n){for(auto s:strips)s->createPalette(p,n);}
void fillScreen(uint16_t c){for(auto s:strips)s->fillSprite(c);}
void setTextWrap(bool b){for(auto s:strips)s->setTextWrap(b);}
void setTextSize(int n){for(auto s:strips)s->setTextSize(n);}
void setTextColor(uint16_t c,uint16_t b){for(auto s:strips)s->setTextColor(c,b);}
void setCursor(int x,int y){for(int i=0;i<8;i++)strips[i]->setCursor(x,y-40*i);}
// Draw only glyphs intersecting each strip. Never run Print's cursor wrapping
// independently on eight short surfaces: it can repeat text at strip edges.
void drawText(int x,int y,const String &v,uint16_t c,int size){
int h=8*size;
for(unsigned int k=0;k<v.length();k++){
int px=x+int(k)*6*size;if(px>=240)break;if(px+6*size<=0)continue;
for(int i=0;i<8;i++)if(y+h>i*40&&y<(i+1)*40)
strips[i]->drawChar(px,y-i*40,(unsigned char)v[k],c,c,size);
}
}
void fillRect(int x,int y,int w,int h,uint16_t c){for(int i=0;i<8;i++)if(y+h>i*40&&y<(i+1)*40)strips[i]->fillRect(x,y-i*40,w,h,c);}
void fillRoundRect(int x,int y,int w,int h,int r,uint16_t c){for(int i=0;i<8;i++)if(y+h>i*40&&y<(i+1)*40)strips[i]->fillRoundRect(x,y-i*40,w,h,r,c);}
void drawPixel(int x,int y,uint16_t c){if(y>=0&&y<320)strips[y/40]->drawPixel(x,y%40,c);}
void drawLine(int x,int y,int u,int v,uint16_t c){for(int i=0;i<8;i++)strips[i]->drawLine(x,y-i*40,u,v-i*40,c);}
void drawCircle(int x,int y,int r,uint16_t c){for(int i=0;i<8;i++)if(y+r>=i*40&&y-r<(i+1)*40)strips[i]->drawCircle(x,y-i*40,r,c);}
void fillCircle(int x,int y,int r,uint16_t c){for(int i=0;i<8;i++)if(y+r>=i*40&&y-r<(i+1)*40)strips[i]->fillCircle(x,y-i*40,r,c);}
void invalidate(){for(int i=0;i<8;i++)sent[i]=false;}
void pushSprite(int x,int y){
// Hash the packed 4-bit pixels, not an extra framebuffer. Unchanged strips
// stay on the LCD; a one-second clock update no longer sends the whole screen.
for(int i=0;i<8;i++){
uint64_t h=14695981039346656037ULL;
for(int j=0;j<4800;j++){h^=pixels[i][j];h*=1099511628211ULL;}
if(!sent[i]||sentHash[i]!=h){strips[i]->pushSprite(x,y+i*40);sentHash[i]=h;sent[i]=true;}
}
}
};
StripFrame frame;
bool frameReady=false, painted=false;
TinyGPSPlus gps;
HardwareSerial gpsPort(2);
bool gpsFix=false;
double myLat=0,myLon=0;
float centerLon();
extern bool dirty;
const uint32_t gpsRates[]={9600,38400,115200,57600,19200,4800};
int gpsRateIndex=0,gpsValidSentences=0;
bool gpsBaudLocked=false;
uint32_t gpsRateSince=0,gpsLastSentence=0;
void gpsBaudLoop(){
if(gpsBaudLocked && millis()-gpsLastSentence>20000){gpsBaudLocked=false;gpsValidSentences=0;gpsRateSince=millis();dirty=true;}
if(!gpsBaudLocked && millis()-gpsRateSince>=4500){
gpsRateIndex=(gpsRateIndex+1)%6;gpsPort.updateBaudRate(gpsRates[gpsRateIndex]);
gpsValidSentences=0;gpsRateSince=millis();dirty=true;
}
}
void gpsLoop(){
while(gpsPort.available())if(gps.encode(gpsPort.read())){
gpsLastSentence=millis();if(++gpsValidSentences>=2&&!gpsBaudLocked){gpsBaudLocked=true;dirty=true;Serial.printf("GPS NMEA valido: %lu baudios\n",(unsigned long)gpsRates[gpsRateIndex]);}
}
gpsBaudLoop();
static uint32_t tick=0;
if(millis()-tick<1000)return;tick=millis();
bool fix=gps.location.isValid() && gps.location.age()<15000;
if(fix!=gpsFix || (fix&&(fabs(myLat-gps.location.lat())>.0001 || fabs(myLon-gps.location.lng())>.0001))){
gpsFix=fix;if(fix){myLat=gps.location.lat();myLon=gps.location.lng();}extern bool dirty;dirty=true;
}
}
WebServer server(80);
Preferences prefs;
// A persistent 4-bit frame uses 38,400 bytes instead of 76,800.
// All sprite drawing uses palette indices, not RGB565 values.
const uint16_t BG=0, GRID=3;
const uint16_t C_WHITE=1,C_CYAN=2,C_GREEN=4,C_YELLOW=5,C_RED=6;
const uint16_t palette[16]={0x0000,0xF7BE,0xBDF7,0x3186,0x7E4D,0xF68A,0xF9E7,0x9CF3,
0x1082,0x0927,0x11CD,0x22B2,0x2244,0x4B88,0x7CCD,0xC52C};
bool valid=false,ap=true,pending=false;
bool wifiConnecting=false;
bool wifiScanAllowed(){return !wifiConnecting;}
double lat=0,lon=0,alt=0,vel=0;
float centerLon(){return valid?lon:(gpsFix?myLon:0);}
uint32_t stamp=0,lastGood=0,lastPoll=0,lastTry=0;
// Fixed 720-byte history: 180 received positions, no heap allocations.
struct TrailPoint { int16_t lat100,lon100; };
TrailPoint issTrail[180];
uint16_t trailHead=0,trailCount=0;
void recordTrail(double la,double lo,uint32_t ts){
// Do not bridge outages with a misleading straight segment.
if(valid && ts-stamp>60){trailHead=0;trailCount=0;}
issTrail[trailHead]={int16_t(lround(la*100)),int16_t(lround(lo*100))};
trailHead=(trailHead+1)%180;if(trailCount<180)trailCount++;
}
bool imperialUnits=false;
bool clock12h=false;
bool zoneKnown=false, summerTime=false;
int32_t localOffset=0;
String localZone;
uint32_t zoneUpdated=0;
String status="Esperando Wi-Fi", ssid,pass;
void text(int x,int y,String s,uint16_t c=C_WHITE,int size=1){painted=true;frame.drawText(x,y,s,c,size);}
// Equirectangular map, longitude -180..180, latitude 90..-90.
bool project(float la,float lo,int &x,int &y){
x=constrain(int(roundf((lo+180)*239/360)),0,239);
y=72+constrain(int(roundf((90-la)*119/180)),0,119);return true;
}
int page=0; bool dirty=true; uint32_t viewRevision=0;
// UTC calendar conversion independent of local timezone and DST.
int64_t utcDays(int y,unsigned m,unsigned d){
y-=m<=2;const int era=(y>=0?y:y-399)/400;
const unsigned yo=y-era*400,mp=m>2?m-3:m+9;
const unsigned doy=(153*mp+2)/5+d-1;
return int64_t(era)*146097+yo*365+yo/4-yo/100+doy-719468;
}
bool solarVector(float &sx,float &sy,float &sz){
time_t utc=time(nullptr);
if(gps.date.isValid()&&gps.time.isValid()&&gps.date.age()<3000&&gps.time.age()<3000&&gps.date.year()>=2020){
utc=utcDays(gps.date.year(),gps.date.month(),gps.date.day())*86400+
gps.time.hour()*3600+gps.time.minute()*60+gps.time.second();
}
if(utc<1600000000)return false;
// Low-cost solar ephemeris: apparent ecliptic longitude, obliquity,
// right ascension and Greenwich sidereal angle (all based on UTC).
double days=double(utc)/86400.0+2440587.5-2451545.0;
double mean=fmod(280.460+0.9856474*days,360.0)*DEG_TO_RAD;
double anomaly=fmod(357.528+0.9856003*days,360.0)*DEG_TO_RAD;
double ecliptic=mean+(1.915*sin(anomaly)+.020*sin(2*anomaly))*DEG_TO_RAD;
double obliquity=(23.439-.0000004*days)*DEG_TO_RAD;
double ra=atan2(cos(obliquity)*sin(ecliptic),cos(ecliptic));
double dec=asin(sin(obliquity)*sin(ecliptic));
double sidereal=fmod(280.46061837+360.98564736629*days,360.0)*DEG_TO_RAD;
double relative=ra-sidereal-centerLon()*DEG_TO_RAD;
sx=cos(dec)*sin(relative);sy=sin(dec);sz=cos(dec)*cos(relative);
return true;
}
void worldMap(){
if(page!=0)return;
painted=true;frame.fillRect(0,42,240,186,BG);
// Packed image is read directly from flash into the existing frame.
for(int y=0;y<120;y++)for(int x=0;x<240;x++){
int pos=y*240+x;uint8_t pair=pgm_read_byte(worldMapPacked+pos/2);
frame.drawPixel(x,72+y,(pos&1)?pair&15:pair>>4);
}
float sx=0,sy=0,sz=0;bool solarKnown=solarVector(sx,sy,sz);
String daylight=solarKnown?"MAPA MUNDIAL":"Sin hora: luz pendiente";
if(solarKnown&&gpsFix){
float a=myLat*DEG_TO_RAD,b=(myLon-centerLon())*DEG_TO_RAD;
float elevation=cosf(a)*sinf(b)*sx+sinf(a)*sy+cosf(a)*cosf(b)*sz;
daylight=elevation>0?"TU ZONA: DIA":(elevation>-.105f?"TU ZONA: CREPUSCULO":"TU ZONA: NOCHE");
}
text(8,49,daylight,solarKnown?C_WHITE:7);
// Draw the observed track before the markers. Split at the date line.
for(uint16_t i=1;i<trailCount;i++){
const TrailPoint &a=issTrail[(trailHead+180-trailCount+i-1)%180];
const TrailPoint &b=issTrail[(trailHead+180-trailCount+i)%180];
int x,y,u,v;project(a.lat100*.01f,a.lon100*.01f,x,y);
project(b.lat100*.01f,b.lon100*.01f,u,v);
if(abs(int(b.lon100)-int(a.lon100))<=18000){
// Dark casing keeps the measured path legible over bright land.
for(int dy=-2;dy<=2;dy++)for(int dx=-2;dx<=2;dx++)
frame.drawLine(x+dx,y+dy,u+dx,v+dy,BG);
}
}
// Separate foreground pass prevents later outlines erasing earlier segments.
for(uint16_t i=1;i<trailCount;i++){
const TrailPoint &a=issTrail[(trailHead+180-trailCount+i-1)%180];
const TrailPoint &b=issTrail[(trailHead+180-trailCount+i)%180];
int x,y,u,v;project(a.lat100*.01f,a.lon100*.01f,x,y);
project(b.lat100*.01f,b.lon100*.01f,u,v);
if(abs(int(b.lon100)-int(a.lon100))<=18000){
frame.drawLine(x,y,u,v,C_YELLOW);
frame.drawLine(x,y+1,u,v+1,C_YELLOW);
}
}
if(gpsFix){int x,y;if(project(myLat,myLon,x,y)){frame.drawCircle(x,y,6,C_WHITE);frame.fillCircle(x,y,3,C_GREEN);}}
// Satellite is the topmost map layer; use primitives, no bitmap or heap buffer.
if(valid){
int x,y;project(lat,lon,x,y);
int cx=constrain(x,12,227),cy=constrain(y,80,183);
// Keep the true coordinate visible when the icon is inset at a map edge.
frame.drawLine(x,y,cx,cy,BG);
frame.drawLine(cx-10,cy,cx+10,cy,BG);
frame.fillRect(cx-12,cy-6,9,13,BG);
frame.fillRect(cx+4,cy-6,9,13,BG);
frame.fillRect(cx-3,cy-5,7,11,BG);
frame.drawLine(cx-10,cy,cx+10,cy,C_WHITE);
frame.fillRect(cx-11,cy-5,7,11,C_YELLOW);
frame.fillRect(cx+5,cy-5,7,11,C_YELLOW);
for(int dy=-3;dy<=3;dy+=3){
frame.drawLine(cx-10,cy+dy,cx-6,cy+dy,BG);
frame.drawLine(cx+6,cy+dy,cx+10,cy+dy,BG);
}
frame.fillRect(cx-2,cy-4,5,9,C_WHITE);
frame.drawPixel(x,y,C_WHITE);
}
text(8,195,"ISS",C_YELLOW);text(45,195,"TU POSICION",C_GREEN);text(153,195,"TRAZA REAL",C_YELLOW);
text(8,207,String(gpsBaudLocked?"GPS ":"Buscando GPS ")+String(gpsRates[gpsRateIndex])+" baud",C_WHITE);
text(8,221,gpsFix?"TU: "+String(myLat,3)+","+String(myLon,3):"GPS: esperando posicion",C_GREEN);
}
void hud(){
if(page!=0)return;
uint32_t age=valid?(millis()-lastGood)/1000:0;
String state=WiFi.status()!=WL_CONNECTED?"SIN WI-FI":(!valid?status:(age>20?"DATOS ANTIGUOS":status));
String key=state+String(age)+String(stamp)+String(ap)+String(viewRevision)+String(millis()/1000)+String(zoneUpdated);
static String old="";if(key==old)return;old=key;
// The shared status row is owned exclusively by passBanner().
frame.fillRect(0,230,240,56,BG);
if(valid){
frame.drawLine(8,230,232,230,GRID);
text(10,234,"ISS "+String(lat,2)+", "+String(lon,2),C_YELLOW);
if(alt>0 && vel>0){double scale=imperialUnits?0.621371192237334:1.0;text(10,253,String(alt*scale,0)+(imperialUnits?" mi":" km"),C_CYAN);text(100,253,String(vel*scale,0)+(imperialUnits?" mph":" km/h"),C_CYAN);}else text(10,253,"Fuente: solo posicion (sin alt/vel)",7);
time_t now=time(nullptr);
if(now<1600000000)now=stamp+age;
bool local=zoneKnown;
time_t ts=now+(local?localOffset:0);struct tm tm;gmtime_r(&ts,&tm);
char clock[32];
const char* format=clock12h?(local?"Local %I:%M:%S %p":"UTC %I:%M:%S %p"):(local?"Local %H:%M:%S":"UTC %H:%M:%S");
strftime(clock,sizeof(clock),format,&tm);
text(8,267,clock,C_WHITE);
text(8,278,local?localZone.substring(0,18)+(summerTime?" DST":" STD")+(zoneUpdated?"":" *"):"Zona pendiente / hora UTC",7);
}else{text(8,233,"1. Wi-Fi: ISS-Config",C_YELLOW);text(8,248,"Clave: orbita2026",C_YELLOW);text(8,263,"2. Abre 192.168.4.1",C_YELLOW);}
}
// Geometric elevation above the local horizon; not an orbit/pass predictor.
// A 10 degree acquisition threshold avoids most near-horizon obstructions.
bool passActive=false,passAcknowledged=false,passFresh=false;
double passElevation=0;
void passLoop(){
bool fresh=gpsFix&&valid&&alt>0&&(millis()-lastGood<20000)&&WiFi.status()==WL_CONNECTED;
bool previous=passActive,oldFresh=passFresh;passFresh=fresh;
if(fresh){
const double rad=PI/180.0,R=6371.0;
double a=myLat*rad,b=lat*rad,d=(lon-myLon)*rad;
double cosine=constrain(sin(a)*sin(b)+cos(a)*cos(b)*cos(d),-1.0,1.0);
double radius=R+alt;
passElevation=atan2(radius*cosine-R,radius*sqrt(max(0.0,1-cosine*cosine)))/rad;
if(!passActive&&passElevation>=10){passActive=true;passAcknowledged=false;Serial.println("ISS: paso sobre horizonte >=10 grados");}
else if(passActive&&passElevation<5){passActive=false;passAcknowledged=false;}
}
// Stale data never leaves a live contact alert on screen. Preserve the latch
// across a short outage so reconnecting does not repeat an acknowledged alert.
if(previous!=passActive||oldFresh!=passFresh)dirty=true;
}
void passBanner(){
static String previous;
String label;
if(passFresh&&passActive&&!passAcknowledged)label="ISS EN ALCANCE > TOCA PARA RADIO";
else if(page==0||page==3){
if(!gpsFix)label="AVISO ISS: esperando posicion GPS";
else if(!passFresh)label="AVISO ISS: sin datos recientes";
else label="ISS elev. "+String(passElevation,0)+" grados / aviso a 10";
}
if(!label.length()&&page==0){
uint32_t age=valid?(millis()-lastGood)/1000:0;
label=WiFi.status()!=WL_CONNECTED?"SIN WI-FI":(!valid?status:(age>20?"DATOS ANTIGUOS":status));
}
String key=String(page)+":"+String(viewRevision)+":"+label;
if(key==previous)return;previous=key;
if(label.length()){frame.fillRect(0,31,240,16,BG);text(8,36,label,passActive&&passFresh?C_YELLOW:7);}
}
// HTTPClient decodes chunked responses into this bounded sink. Never let a
// missing Content-Length or an oversized response consume the remaining heap.
class BoundedBody : public Stream {
public:
String body; size_t limit; bool overflow=false;
explicit BoundedBody(size_t n):limit(n){if(!body.reserve(n))overflow=true;}
size_t write(uint8_t c) override {return write(&c,1);}
size_t write(const uint8_t* p,size_t n) override {
if(overflow || n>limit-body.length()){overflow=true;return 0;}
if(!body.concat(reinterpret_cast<const char*>(p),n)){overflow=true;return 0;}
return n;
}
int available() override{return 0;}
int read() override{return -1;}
int peek() override{return -1;}
void flush() override{}
};
String readBounded(HTTPClient &http,size_t limit){
if(http.getSize()>int(limit))return String();
BoundedBody sink(limit);
if(sink.overflow || http.writeToStream(&sink)<0 || sink.overflow)return String();
return std::move(sink.body);
}
// Only numeric JSON fields from the documented public endpoint are needed.
bool number(const String &body,const char* field,double &out){String key=String('"')+field+'"';int i=body.indexOf(key);if(i<0)return false;i=body.indexOf(':',i+key.length());if(i<0)return false;const char* p=body.c_str()+i+1;char *end;while(*p==' '||*p=='\t'||*p=='\n'||*p=='\r')++p;if(*p=='"')++p;out=strtod(p,&end);return end!=p&&isfinite(out);}
// Networking owns no display or UI state. A one-slot queue publishes complete results.
struct ISSResult { double la,lo,al,ve; uint32_t ts; bool ok; char message[48];
bool zoneOk,dst; int32_t offset; uint32_t clockEpoch; char zone[64]; };
QueueHandle_t issResults=nullptr;
TaskHandle_t issTask=nullptr;
bool httpBusy=false;
String jsonText(const String &body,const char* field){
String key=String('"')+field+'"';int i=body.indexOf(key);if(i<0)return "";
i=body.indexOf(':',i+key.length());if(i<0)return "";
int a=body.indexOf('"',i+1);if(a<0)return "";int b=body.indexOf('"',a+1);
return b>a?body.substring(a+1,b):String("");
}
void issWorker(void*){
uint32_t zoneAttempt=0,tlsFailedAt=0;bool zoneSuccess=false;
for(;;){
ulTaskNotifyTake(pdTRUE,portMAX_DELAY);
ISSResult r={};
if(!zoneAttempt || millis()-zoneAttempt>=(zoneSuccess?300000UL:60000UL)){
zoneAttempt=millis();WiFiClient clockClient;HTTPClient clockHttp;
clockHttp.setConnectTimeout(2500);clockHttp.setTimeout(2500);
if(clockHttp.begin(clockClient,"http://worldtimeapi.org/api/ip") && clockHttp.GET()==200){
String body=readBounded(clockHttp,4096);
double raw,dst,epoch;String zone=jsonText(body,"timezone");
if(number(body,"raw_offset",raw)&&number(body,"dst_offset",dst)&&number(body,"unixtime",epoch)&&epoch>1600000000&&zone.length()&&fabs(raw+dst)<=50400){
r.zoneOk=true;r.offset=(int32_t)(raw+dst);r.dst=dst!=0;r.clockEpoch=(uint32_t)epoch;
zone.toCharArray(r.zone,sizeof(r.zone));
Serial.printf("Reloj: zona=%s UTC%+ld segundos, verano=%s\n",r.zone,(long)r.offset,r.dst?"si":"no");
}
}
clockHttp.end();clockClient.stop();
if(!r.zoneOk){
WiFiClient zoneClient;HTTPClient zh;
zh.setConnectTimeout(2500);zh.setTimeout(2500);
int zc=zh.begin(zoneClient,"http://ipwho.is/?fields=success,timezone")?zh.GET():-1;
if(zc==200){
String b=readBounded(zh,4096);
String z=jsonText(b,"id"),ct=jsonText(b,"current_time");double off;
int di=b.indexOf("\"is_dst\"");int dc=di>=0?b.indexOf(':',di):-1;
String flag=dc>=0?b.substring(dc+1):String("");flag.trim();
if(z.length()&&number(b,"offset",off)&&fabs(off)<=50400&&(flag.startsWith("true")||flag.startsWith("false"))){
r.zoneOk=true;r.offset=(int32_t)off;r.dst=flag.startsWith("true");z.toCharArray(r.zone,sizeof(r.zone));
int y,mo,d,h,mi,se;
if(sscanf(ct.c_str(),"%d-%d-%dT%d:%d:%d",&y,&mo,&d,&h,&mi,&se)==6&&y>=2024){
struct tm tm={};tm.tm_year=y-1900;tm.tm_mon=mo-1;tm.tm_mday=d;tm.tm_hour=h;tm.tm_min=mi;tm.tm_sec=se;
r.clockEpoch=mktime(&tm)-r.offset;
}
}
}
zh.end();zoneClient.stop();
if(!r.zoneOk)Serial.printf("Reloj: servicios no disponibles (alternativa=%d); reintento en 60s\n",zc);
}
zoneSuccess=r.zoneOk;
if(r.zoneOk)Serial.printf("Zona detectada: %s offset=%ld DST=%d\n",r.zone,(long)r.offset,r.dst);
}
// The telemetry endpoint redirects HTTP to HTTPS; use TLS directly.
// TLS needs a large contiguous block. Back off after a failed handshake.
bool tryTLS=heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)>=20000 &&
ESP.getFreeHeap()>=55000 &&
(!tlsFailedAt||millis()-tlsFailedAt>=60000UL);
int code=0;
if(!r.ok&&tryTLS){
WiFiClientSecure client;client.setInsecure();client.setTimeout(5);
client.setHandshakeTimeout(5);
HTTPClient http;http.setConnectTimeout(4000);http.setTimeout(4000);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
code=http.begin(client,"https://api.wheretheiss.at/v1/satellites/25544")?http.GET():-1;
if(code==200){
String body=readBounded(http,2048);double t,id;
r.ok=number(body,"latitude",r.la)&&number(body,"longitude",r.lo)&&number(body,"altitude",r.al)&&number(body,"velocity",r.ve)&&number(body,"timestamp",t)&&number(body,"id",id)&&id==25544&&fabs(r.la)<=90&&fabs(r.lo)<=180&&r.al>0&&r.ve>0&&t>1600000000;
if(r.ok)r.ts=(uint32_t)t;
snprintf(r.message,sizeof(r.message),"%s",r.ok?"ISS / actualizada cada 5s":"Respuesta no valida");
}else snprintf(r.message,sizeof(r.message),"API %d / reintentando",code);
http.end();client.stop();
if(!r.ok){tlsFailedAt=millis();Serial.printf("ISS: telemetria HTTPS fallo=%d; reintento en 60s\n",code);}
}
if(!r.ok){
if(!tryTLS&&(!tlsFailedAt||millis()-tlsFailedAt>=60000UL)){
Serial.printf("ISS: HTTPS aplazado; libre=%u bloque=%u; alternativa solo posicion\n",(unsigned)ESP.getFreeHeap(),(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
tlsFailedAt=millis();
}
// Public position-only fallback avoids the TLS heap requirement. No user
// position, saved password or other private data is sent to this service.
WiFiClient plain;
HTTPClient fallback;fallback.setConnectTimeout(4000);fallback.setTimeout(4000);
int fallbackCode=fallback.begin(plain,"http://api.open-notify.org/iss-now.json")?fallback.GET():-1;
if(fallbackCode==200){
String body=readBounded(fallback,2048);double t;
r.ok=body.indexOf("iss_position")>=0 && body.indexOf("success")>=0 && number(body,"latitude",r.la)&&number(body,"longitude",r.lo)&&number(body,"timestamp",t)&&fabs(r.la)<=90&&fabs(r.lo)<=180&&t>1600000000;
if(r.ok){r.ts=(uint32_t)t;r.al=0;r.ve=0;snprintf(r.message,sizeof(r.message),"ISS / solo posicion / cada 5s");}
}
if(!r.ok)snprintf(r.message,sizeof(r.message),"ISS no disponible (%d)",fallbackCode);
fallback.end();plain.stop();
}
if(r.ok&&r.al>0&&r.ve>0)Serial.printf("ISS: lat=%.4f lon=%.4f UTC=%lu alt=%.2f km vel=%.2f km/h fuente=WTIA HTTPS\n",r.la,r.lo,(unsigned long)r.ts,r.al,r.ve);
else if(r.ok)Serial.printf("ISS: lat=%.4f lon=%.4f UTC=%lu fuente=Open Notify HTTP; altitud/velocidad no suministradas\n",r.la,r.lo,(unsigned long)r.ts);
else Serial.printf("ISS: %s\n",r.message);
xQueueOverwrite(issResults,&r);
}
}
void receiveISS(){
ISSResult r;
if(issResults&&xQueueReceive(issResults,&r,0)==pdTRUE){
httpBusy=false;status=r.message;
if(r.zoneOk){
zoneKnown=true;summerTime=r.dst;localOffset=r.offset;localZone=r.zone;zoneUpdated=millis();
if(r.clockEpoch>1600000000){struct timeval tv;tv.tv_sec=r.clockEpoch;tv.tv_usec=0;settimeofday(&tv,nullptr);}
if(prefs.getString("zone")!=localZone)prefs.putString("zone",localZone);
if(prefs.getInt("offset",0)!=localOffset)prefs.putInt("offset",localOffset);
if(prefs.getBool("dst",false)!=summerTime)prefs.putBool("dst",summerTime);
if(page==0)dirty=true;
}
if(r.ok&&(!valid||r.ts>stamp)){recordTrail(r.la,r.lo,r.ts);lat=r.la;lon=r.lo;alt=r.al;vel=r.ve;stamp=r.ts;lastGood=millis();valid=true;}
else if(r.ok)status="Dato sin actualizar";
if(page==0)dirty=true;
}
}
bool monitorRadioActive();
void initUI();
void uiLoop();
void setup(){
// Reserve display strips before driver/network initialization fragments the heap.
frame.setColorDepth(4);frameReady=frame.createSprite(240,320)!=nullptr;
Serial.begin(115200);
pinMode(21,OUTPUT);digitalWrite(21,HIGH);
pinMode(TFT_BL,OUTPUT);digitalWrite(TFT_BL,LOW);
tft.init();tft.setRotation(0);
if(!frameReady){tft.fillScreen(TFT_BLACK);tft.setTextColor(TFT_RED);tft.drawString("Sin memoria de pantalla",4,100);while(true)delay(1000);}
frame.createPalette(palette,16);frame.fillScreen(BG);frame.setTextWrap(false);digitalWrite(TFT_BL,HIGH);
gpsPort.setRxBufferSize(4096);gpsPort.begin(9600,SERIAL_8N1,4,13);gpsRateSince=millis();
setenv("TZ","UTC0",1);tzset();
prefs.begin("iss-wifi",false);
imperialUnits=prefs.getBool("imperial",false);
clock12h=prefs.getBool("clock12h",false);
localZone=prefs.isKey("zone")?prefs.getString("zone"):String("");localOffset=prefs.getInt("offset",0);summerTime=prefs.getBool("dst",false);zoneKnown=localZone.length()>0;
ssid=prefs.isKey("ssid")?prefs.getString("ssid"):String("");pass=prefs.isKey("pass")?prefs.getString("pass"):String("");
WiFi.mode(WIFI_AP_STA);WiFi.setAutoReconnect(true);WiFi.persistent(false);WiFi.softAP("ISS-Config","orbita2026");
if(ssid.length()){WiFi.begin(ssid.c_str(),pass.c_str());wifiConnecting=true;status="Conectando Wi-Fi...";}
server.on("/",HTTP_GET,[]{server.send(200,"text/html; charset=utf-8",R"HTML(<!doctype html><meta name="viewport" content="width=device-width"><title>ISS Wi-Fi</title><h2>Conectar ISS a Internet</h2><p>Usa una red Wi-Fi de 2,4 GHz. Los datos se guardan en la placa.</p><form method="POST" action="/save"><p>Nombre de red: <input name="ssid" maxlength="32" required></p><p>Clave: <input name="pass" type="password" maxlength="63"></p><button>Guardar y conectar</button></form>)HTML");});
server.on("/save",HTTP_POST,[]{String s=server.arg("ssid"),p=server.arg("pass");if(s.length()<1||s.length()>32||p.length()>63){server.send(400,"text/plain","Datos no validos");return;}ssid=s;pass=p;prefs.putString("ssid",s);prefs.putString("pass",p);pending=true;server.send(200,"text/plain; charset=utf-8","Guardado. Mira la pantalla. Si no conecta en 30 segundos, vuelve a ISS-Config y revisa los datos.");});
server.begin();
initUI();
lastTry=millis();
issResults=xQueueCreate(1,sizeof(ISSResult));
if(issResults && xTaskCreatePinnedToCore(issWorker,"iss-net",8192,nullptr,1,&issTask,0)!=pdPASS)issTask=nullptr;
if(!issTask)status="Sin memoria para red";
}
void loop(){
receiveISS();
gpsLoop();
// The solar boundary moves slowly: refresh only once per minute on this page.
static uint32_t solarTick=0;
if(millis()-solarTick>=60000){solarTick=millis();if(page==0)dirty=true;}
passLoop();
uiLoop();
server.handleClient();uint32_t now=millis();
static int previousWifi=-1;
int wifiState=WiFi.status();
if(wifiState!=previousWifi){
Serial.printf("Wi-Fi: estado=%d, red guardada=%s\n",wifiState,ssid.length()?"si":"no");
if(wifiState==WL_CONNECTED){Serial.println("Wi-Fi: conectado, IP "+WiFi.localIP().toString());configTime(0,0,"pool.ntp.org","time.google.com");}
previousWifi=wifiState;
}
if(pending){
pending=false;WiFi.scanDelete();WiFi.disconnect(false,false);
WiFi.begin(ssid.c_str(),pass.c_str());lastTry=now;wifiConnecting=true;status="Conectando Wi-Fi...";
}
if(WiFi.status()==WL_CONNECTED){
wifiConnecting=false;
// Disable only the configuration AP, keeping station connectivity active.
if(ap){WiFi.softAPdisconnect(false);WiFi.mode(WIFI_STA);ap=false;}
lastTry=now;
if(!monitorRadioActive() && issTask && !httpBusy && (!lastPoll||now-lastPoll>=5000)){lastPoll=now;httpBusy=true;xTaskNotifyGive(issTask);}
}else{
if(wifiConnecting && now-lastTry>=20000){wifiConnecting=false;status="Wi-Fi / reintentando";}
if(now-lastTry>=30000){
if(!ap){WiFi.mode(WIFI_AP_STA);WiFi.softAP("ISS-Config","orbita2026");ap=true;}
if(ssid.length()){
// A fresh attempt also recovers when the driver has stopped auto-retrying.
WiFi.scanDelete();WiFi.disconnect(false,false);
WiFi.begin(ssid.c_str(),pass.c_str());wifiConnecting=true;
status="Conectando Wi-Fi...";Serial.println("Wi-Fi: reintentando red guardada");
}
lastTry=now;
}
}
hud();passBanner();if(painted&&frameReady){frame.pushSprite(0,0);painted=false;}delay(10);
}
// Stored RAM-only identity history; no inference of actual movement or pursuit.
struct Entry {String key; char kind; int rssi; uint32_t first,last,bucket; uint16_t windows;
char name[33]={}; int low=0,high=0; uint32_t samples=0; int8_t history[24]={}; uint8_t hn=0,hp=0;
uint8_t channel=0,auth=0,addressType=0; int tx=127; uint8_t payload[62]={},payloadLen=0; uint16_t payloadTotal=0;
};
int selected=-1,monitorOffset=0,detailTab=0,visible[4]={-1,-1,-1,-1},monitorCount=0;
String cleanName(const String &s){String o;for(unsigned int i=0;i<s.length()&&i<32;i++){char c=s[i];o+=(c>=32&&c<=126)?c:'?';}return o;}
// Allocate history in small blocks only when a new identity is seen. The old
// global array consumed RAM for all 160 devices even before the first scan.
// Allocated records remain intact across tabs; no history is discarded.
class EntryStore {
Entry* blocks[20]={};
public:
bool ensure(int index){
int b=index/8;
if(!blocks[b])blocks[b]=new(std::nothrow) Entry[8];
return blocks[b]!=nullptr;
}
Entry& operator[](int index){return blocks[index/8][index%8];}
};
EntryStore entries; int used=0; uint32_t monitorAt=0;bool bleTurn=false;
NimBLEScan *scanner=nullptr;
bool bleScanning=false;
bool monitorRadioActive(){return scanner!=nullptr;}
String networks[32],chosen,typed; int netCount=0,netOffset=0,keyMode=0;bool scanning=false,editing=false;
int record(String key,char kind,int rssi){
uint32_t now=millis(),bucket=now/60000;int pos=-1;
for(int i=0;i<used;i++)if(entries[i].kind==kind&&entries[i].key==key){pos=i;break;}
if(pos<0){
if(used<160){if(!entries.ensure(used))return -1;pos=used++;}
else{
// Reuse only expired, unselected records. Keep the opened detail stable.
uint32_t oldest=120000;
for(int i=0;i<used;i++)if(i!=selected&&now-entries[i].last>oldest){oldest=now-entries[i].last;pos=i;}
if(pos<0)return -1;
}
entries[pos]={key,kind,rssi,now,now,bucket,1};
}
Entry &e=entries[pos];e.last=now;e.rssi=rssi;
if(!e.samples){e.low=e.high=rssi;}e.low=min(e.low,rssi);e.high=max(e.high,rssi);e.samples++;
e.history[e.hp]=rssi;e.hp=(e.hp+1)%24;if(e.hn<24)e.hn++;
if(bucket!=e.bucket){e.bucket=bucket;if(e.windows<65535)e.windows++;}return pos;
}
// Monochrome surfaces: colour is reserved for data and small status accents.
void outline(int x,int y,int w,int h,uint16_t c=GRID){
frame.drawLine(x,y,x+w-1,y,c);frame.drawLine(x,y+h-1,x+w-1,y+h-1,c);
frame.drawLine(x,y,x,y+h-1,c);frame.drawLine(x+w-1,y,x+w-1,y+h-1,c);
}
void header(String title,String detail){
frame.fillRect(0,0,240,287,BG);
text(9,8,title,C_WHITE,2);
frame.drawLine(9,28,231,28,GRID);
text(9,36,detail,7);
}
void button(int x,int y,int w,int h,String label){
frame.fillRect(x,y,w,h,BG);
outline(x+1,y+1,w-2,h-2);
label=label.substring(0,max(0,(w-8)/6));
text(x+max(4,(w-int(label.length())*6)/2),y+(h-8)/2,label,C_WHITE);
}
void tabs(){
frame.fillRect(0,287,240,33,BG);
frame.drawLine(0,288,239,288,GRID);
const char* names[]={"ISS","WI-FI","MONITOR"};
for(int i=0;i<3;i++){
int x=i*81;bool active=(page==i||(page==3&&i==0));
if(active)frame.drawLine(x+16,318,x+62,318,C_CYAN);
text(x+(78-int(strlen(names[i]))*6)/2,302,names[i],active?C_WHITE:7);
}
}
void drawRadio(){
header("RADIO ISS <","ARISS / REFERENCIA DE FRECUENCIAS");
outline(6,53,228,105);
text(14,61,"REPETIDOR FM / NA1SS",C_CYAN);
text(14,81,"TX 145.990",C_YELLOW,2);text(164,89,"MHz",7);
text(14,106,"RX 437.800",C_GREEN,2);text(164,114,"MHz",7);
text(14,137,"CTCSS 67.0 Hz / radio externa",C_WHITE);
text(10,172,"APRS / RS0ISS",C_CYAN);text(10,187,"437.825 MHz",C_WHITE,2);
text(10,215,"HAM TV 2395.000 MHz",C_CYAN);
text(10,238,"Estado NO consultado en vivo",C_YELLOW);
text(10,252,"Referencia ARISS: 04/11/2026",7);
text(10,267,"TX requiere autorizacion",7);tabs();
}
void drawWifi(){
header("RED WI-FI",WiFi.status()==WL_CONNECTED?"ONLINE / "+WiFi.SSID().substring(0,26):"OFFLINE / selecciona red 2.4 GHz");
if(editing){
text(8,52,chosen.substring(0,35));text(8,70,"Clave: "+String(typed.length())+" caracteres");
text(8,86,"Clave oculta / guardado automatico",C_YELLOW);
String keys=keyMode==0?"qwertyuiopasdfghjkl zxcvbnm ":keyMode==1?"QWERTYUIOPASDFGHJKL ZXCVBNM ":"1234567890!@#$%^&*()-_=+[]{}?";
for(int i=0;i<30;i++)button((i%10)*24,104+(i/10)*33,23,31,i<(int)keys.length()?String(keys[i]):String(""));
button(0,207,59,31,"aA/1");button(61,207,79,31,"Espacio");button(142,207,98,31,"Borrar");
button(0,245,118,34,"Cancelar");button(121,245,119,34,"Conectar");
}else{
if(!netCount){text(12,94,scanning?"BUSCANDO REDES...":"SIN REDES EN LA LISTA",C_WHITE);text(12,113,"Pulsa Buscar para actualizar",7);}
for(int i=0;i<5;i++){int n=netOffset+i;if(n<netCount)button(4,54+i*34,232,31,networks[n].substring(0,37));}
button(0,235,78,40,scanning?"Buscando":"Buscar");button(81,235,78,40,"Mas");button(162,235,78,40,"Calibrar");
button(4,276,150,12,imperialUnits?"Estandar (mi, mph)":"Metrico (km, km/h)");
button(156,276,80,12,clock12h?"Reloj: 12h":"Reloj: 24h");
}
tabs();
}
void drawMonitor(){
if(selected>=0&&selected<used){
auto &e=entries[selected];header("< DISPOSITIVO",String(e.kind=='B'?"BLE / ":"WI-FI AP / ")+e.key);
text(10,54,e.name[0]?String(e.name):"Nombre no anunciado",C_CYAN);
if(detailTab==0){
text(12,76,String(e.rssi),C_WHITE,3);text(82,91,"dBm / ultimo RSSI",7);
text(12,108,"MIN "+String(e.low)+" MAX "+String(e.high),C_CYAN);
outline(10,126,220,59);text(14,130,"RSSI / ultimas 24 muestras",7);
for(int i=1;i<e.hn;i++){int a=(e.hp+24-e.hn+i-1)%24,b=(a+1)%24;
frame.drawLine(15+(i-1)*9,179-constrain(int(e.history[a])+100,0,70)/2,15+i*9,179-constrain(int(e.history[b])+100,0,70)/2,C_CYAN);}
text(12,194,"Muestras "+String(e.samples)+" / ventanas "+String(e.windows));
text(12,210,"Visto hace "+String((millis()-e.last)/1000)+"s",C_GREEN);
text(12,225,"Primera vez: hace "+String((millis()-e.first)/1000)+"s",7);
text(12,241,"RSSI no indica distancia exacta",C_YELLOW);
}else{
if(e.kind=='W'){
text(12,78,"BSSID: "+e.key);text(12,99,"Canal: "+String(e.channel),C_CYAN);
String security=e.auth==0?"Abierta":e.auth==1?"WEP":e.auth==2?"WPA PSK":e.auth==3?"WPA2 PSK":e.auth==4?"WPA/WPA2 PSK":e.auth==5?"WPA2 Enterprise":e.auth==6?"WPA3 PSK":"Tipo "+String(e.auth);
text(12,120,"Seguridad: "+security);text(12,152,"Solo puntos de acceso anunciados",7);
text(12,173,"Dueno y modelo: no disponibles",7);
}else{
text(12,76,"Tipo direccion BLE: "+String(e.addressType));
text(12,91,e.tx==127?"TX: no anunciado":"TX anunciado: "+String(e.tx)+" dBm",C_CYAN);
text(12,110,"Anuncio HEX / "+String(e.payloadTotal)+" bytes",7);
for(int row=0;row<8;row++){String line;for(int j=0;j<8;j++){int n=row*8+j;if(n>=e.payloadLen)break;char h[4];snprintf(h,sizeof(h),"%02X ",e.payload[n]);line+=h;}text(12,126+row*13,line);}
if(e.payloadTotal>62)text(12,235,"Vista limitada a 62 bytes",C_YELLOW);
}
}
button(6,258,108,24,"< Lista");button(120,258,114,24,detailTab?"RSSI / grafica":"Datos / HEX");tabs();return;
}
header("MONITOR / RF","BLE + WI-FI / toca una tarjeta");
int list[160],count=0,b=0,w=0;
for(int i=0;i<used;i++)if(millis()-entries[i].last<120000){list[count++]=i;if(entries[i].kind=='B')b++;else w++;}
// Insertion sort avoids swapping String-bearing records and is cheap for small lists.
for(int i=1;i<count;i++){int idx=list[i],j=i;while(j>0&&entries[list[j-1]].rssi<entries[idx].rssi){list[j]=list[j-1];j--;}list[j]=idx;}
monitorCount=count;if(monitorOffset>=count)monitorOffset=0;
text(10,55,"BLE "+String(b),C_CYAN);text(83,55,"WIFI "+String(w),C_GREEN);text(163,55,"TOTAL "+String(count),7);
for(int i=0;i<4;i++){visible[i]=-1;int n=monitorOffset+i;if(n>=count)continue;int idx=list[n];visible[i]=idx;auto &e=entries[idx];int y=73+i*43;
frame.fillRoundRect(6,y,228,39,5,BG);outline(6,y,228,39);frame.fillRect(6,y+6,3,27,e.kind=='B'?C_CYAN:C_GREEN);
text(14,y+5,(e.kind=='B'?"BLE ":"WIFI ")+String(e.name[0]?e.name:"Sin nombre").substring(0,23));
text(14,y+20,e.key,7);text(132,y+20,String(e.rssi),C_WHITE);
int bars=constrain((e.rssi+100)/12,1,5);for(int k=0;k<5;k++)frame.fillRect(204+k*5,y+14-k*2,3,4+k*2,k<bars?C_GREEN:GRID);
}
if(!count){text(16,117,"BUSCANDO SENALES...",C_CYAN);text(16,139,"Espera el siguiente barrido",7);}
button(6,250,58,23,"<");text(77,258,String(count?monitorOffset/4+1:0)+" / "+String((count+3)/4),7);button(176,250,58,23,">");
text(8,278,"Repetido no prueba seguimiento",C_YELLOW);tabs();
}
void calibrate(){uint16_t data[5];tft.fillScreen(TFT_BLACK);tft.setTextColor(TFT_WHITE,TFT_BLACK);tft.drawString("Toca las esquinas indicadas",8,20);tft.calibrateTouch(data,TFT_WHITE,TFT_BLACK,15);prefs.putBytes("touch",data,sizeof(data));tft.setTouch(data);frame.invalidate();dirty=true;}
void initUI(){
uint16_t data[5];if(prefs.getBytesLength("touch")==sizeof(data)){prefs.getBytes("touch",data,sizeof(data));tft.setTouch(data);}else calibrate();
}
void uiLoop(){
// BLE memory is only needed on the monitor tab, never during HTTPS.
if(page==2&&!scanner&&!httpBusy){
NimBLEDevice::init("");scanner=NimBLEDevice::getScan();scanner->setActiveScan(false);scanner->setInterval(100);scanner->setWindow(60);scanner->setMaxResults(80);
}else if(page!=2&&scanner){scanner->stop();bleScanning=false;NimBLEDevice::deinit(true);scanner=nullptr;}
if(scanning){int n=WiFi.scanComplete();if(n>=0){netCount=0;for(int i=0;i<n;i++){
int idx=record(WiFi.BSSIDstr(i),'W',WiFi.RSSI(i));if(idx>=0){auto &e=entries[idx];cleanName(WiFi.SSID(i)).toCharArray(e.name,sizeof(e.name));e.channel=WiFi.channel(i);e.auth=WiFi.encryptionType(i);}String s=WiFi.SSID(i);bool exists=false;for(int k=0;k<netCount;k++)if(networks[k]==s)exists=true;
if(!exists&&s.length()&&netCount<32)networks[netCount++]=s;}
WiFi.scanDelete();scanning=false;netOffset=0;dirty=true;
}else if(n==WIFI_SCAN_FAILED){scanning=false;dirty=true;}}
uint16_t x,y;static bool held=false;bool down=tft.getTouch(&x,&y);if(down&&!held){
if(y>=31&&y<48&&passFresh&&passActive&&!passAcknowledged){passAcknowledged=true;page=3;dirty=true;}
else if(y>=289){int next=min(int(x/81),2);if(next!=page){page=next;dirty=true;}}
else if((page==0||page==3)&&y<25){page=page==0?3:0;dirty=true;}
else if(page==2){
if(selected>=0){if(y<29||(y>=258&&y<282&&x<118))selected=-1;else if(y>=258&&y<282)detailTab=1-detailTab;}
else if(y>=73&&y<245){int row=(y-73)/43;if(row<4&&visible[row]>=0){selected=visible[row];detailTab=0;}}
else if(y>=250&&y<274){if(x<65)monitorOffset=max(0,monitorOffset-4);else if(x>=176&&monitorOffset+4<monitorCount)monitorOffset+=4;}
dirty=true;
}
else if(page==1){
if(editing){
if(y>=104&&y<203){int i=((y-104)/33)*10+x/24;String keys=keyMode==0?"qwertyuiopasdfghjkl zxcvbnm ":keyMode==1?"QWERTYUIOPASDFGHJKL ZXCVBNM ":"1234567890!@#$%^&*()-_=+[]{}?";if(i<(int)keys.length()&&typed.length()<63)typed+=keys[i];}
else if(y>=207&&y<239){if(x<60)keyMode=(keyMode+1)%3;else if(x<141){if(typed.length()<63)typed+=' ';}else if(typed.length())typed.remove(typed.length()-1);}
else if(y>=245){if(x>=121){ssid=chosen;pass=typed;prefs.putString("ssid",ssid);prefs.putString("pass",pass);pending=true;}typed="";editing=false;}
dirty=true;
}else if(y>=276&&y<289){
if(x>=156){clock12h=!clock12h;prefs.putBool("clock12h",clock12h);}
else{imperialUnits=!imperialUnits;prefs.putBool("imperial",imperialUnits);}
dirty=true;}
else if(y>=54&&y<224){int i=netOffset+(y-54)/34;if(i<netCount){chosen=networks[i];typed="";editing=true;dirty=true;}}
else if(y>=235&&y<275){if(x<80&&!scanning&&wifiScanAllowed()){WiFi.scanDelete();scanning=WiFi.scanNetworks(true,true,true,120)==WIFI_SCAN_RUNNING;}else if(x<161){netOffset+=5;if(netOffset>=netCount)netOffset=0;}else calibrate();dirty=true;}
}
}held=down;
if(bleScanning&&scanner&&!scanner->isScanning()){
auto res=scanner->getResults();
for(int i=0;i<res.getCount();i++){auto d=res.getDevice(i);if(d){int idx=record(String(d->getAddress().toString().c_str()),'B',d->getRSSI());if(idx>=0){auto &e=entries[idx];if(d->haveName())cleanName(String(d->getName().c_str())).toCharArray(e.name,sizeof(e.name));e.addressType=d->getAddressType();e.tx=d->haveTXPower()?d->getTXPower():127;const auto &raw=d->getPayload();e.payloadTotal=raw.size();e.payloadLen=min((int)raw.size(),62);for(int k=0;k<e.payloadLen;k++)e.payload[k]=raw[k];}}}
scanner->clearResults();bleScanning=false;if(page==2)dirty=true;
}
if(page==2&&scanner&&!scanning&&!bleScanning&&wifiScanAllowed()&&millis()-monitorAt>=6000){monitorAt=millis();
if(bleTurn){bleScanning=scanner->start(1200,false);}
else{scanning=WiFi.scanNetworks(true,true,true,120)==WIFI_SCAN_RUNNING;}bleTurn=!bleTurn;dirty=true;
}
static uint32_t monitorTick=0;if(page==2&&selected>=0&&millis()-monitorTick>=1000){monitorTick=millis();dirty=true;}
static int wifiState=-1;int ws=WiFi.status();if(ws!=wifiState){wifiState=ws;dirty=true;}
if(dirty){dirty=false;viewRevision++;if(page==0){header("ORBITA ISS","");text(183,12,"RADIO >",C_CYAN);worldMap();tabs();}else if(page==1)drawWifi();else if(page==2)drawMonitor();else drawRadio();}
}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.




