Community project
ESP32 Development Board
The ESP32 Development Board is a versatile microcontroller platform that combines WiFi, Bluetooth Low Energy (BLE), and infrared capabilities in a single board. This guide covers the ESP32-DIV v2 variant, which integrates a 2.4-inch touchscreen display, SD card support, and radio connectivity to create a powerful tool for IoT projects, wireless scanning, and interactive applications.
Builders will receive a complete parts list, wiring diagram, and step-by-step assembly instructions to get the board operational. The included firmware demonstrates WiFi and BLE device scanning, touchscreen interaction, infrared control, and cloud connectivity, providing a solid foundation for custom IoT applications, environmental monitoring, or wireless network analysis projects.
Wiring diagram
Assemble it in 1 step
1. Prepara tu ESP32-DIV v2
Conecta al CC1101 una antena receptora apropiada para la frecuencia que vas a medir. Enchufa el USB de datos y pulsa Deploy.
- En RADAR toca BATERIA/SD para consultar la carga aproximada y PROBAR MICROSD. La prueba monta temporalmente un volumen FAT y muestra hasta tres nombres; no escribe archivos. Los ajustes de clima, IR y radar siguen en la memoria interna.
- Si falla, apaga la placa antes de retirar o insertar la tarjeta. Comprueba sus contactos y prueba una microSD FAT32 de hasta 32 GB. Formatear borra los archivos: haz una copia antes. La bateria se estima en cuatro pasos; sin lectura I2C aparece sin lectura, no 0%.
- SUB-G: SD GUARDAR guarda hasta 64 barridos en /cascada.txt; no sobrescribe un archivo existente. Para guardar otro, apaga la placa y renombra o copia el anterior en Windows. SD CARGAR abre ese archivo y muestra TXT: son datos guardados, no una señal actual. EN VIVO reanuda el CC1101. Retirar la tarjeta solo con la placa apagada.
- El TXT de Sentinela comienza SENTINELA1,433.920,1,41 (centro MHz, 1 para rango amplio o 0 para estrecho, y 41 o 21 valores). Cada linea siguiente contiene 41 o 21 valores enteros de -127 a 0 dBm separados por comas, en orden de frecuencia ascendente; hasta 64 lineas, mas antigua primero. No es el CSV de hackrf_sweep ni una grabacion IQ de HackRF.
- CLIMA busca imagenes opcionales en la carpeta /clima de la microSD: sol_d0.bmp a sol_d3.bmp, sol_n0 a sol_n3, nubes_d0 a nubes_d3, nubes_n0 a nubes_n3, lluvia0 a lluvia3, nieve0 a nieve3, tormenta0 a tormenta3 y niebla0 a niebla3, siempre con extension .bmp. Debes preparar y copiar los archivos: no se incluyen imagenes nuevas. Cada cuadro debe ser BMP sin comprimir de 24 bits y 72 x 62 pixeles. Si falta o no es valido, se usan las animaciones integradas y se vuelve a intentar. La pantalla reduce los colores.
- En SUB-G toca FRECUENCIA, escribe MHz con hasta tres decimales y pulsa ACEPTAR. Se permiten 300–348, 387–464 y 779–928 MHz; la antena y el circuito de radio pueden rendir solo en parte de esos rangos.
- El grafico barre hasta 5 MHz a cada lado del centro; toca RANGO para volver a 2,5 MHz a cada lado. Los rangos se recortan en los limites de banda y la pantalla muestra el ancho realmente visible. Las flechas eligen centros predefinidos. La cascada guarda hasta 64 barridos, el mas reciente arriba. El tiempo en ms es el intervalo medido entre barridos. De izquierda a derecha esta la frecuencia. Los colores se comparan con la mediana de cada barrido, una estimacion del ruido de fondo; azul es cercano al fondo y rojo es al menos 12 dB superior. El color no identifica señales; en DELTA compara la intensidad de cada punto con el barrido anterior mostrado. Cada celda conserva el maximo de varias muestras. Toca UMBRAL para cambiar la alerta absoluta entre -100, -90, -80, -70 y -60 dBm; este boton no cambia los colores. No se decodifican señales.
- Para medir en la frecuencia central seleccionada toca FIJO / DIR. Para usar el centro aproximado de la frecuencia mas fuerte, espera al barrido y toca IR AL PICO. Es una aproximacion, no la frecuencia exacta del emisor. PROMEDIO muestra las ultimas cinco muestras y PICO 5s retiene el maximo por ventanas de cinco segundos. FIJO / DIR usa un filtro de unos 102 kHz. Toca FILTRO para probar 58 kHz: puede reducir el ruido y señales cercanas, pero si las frecuencias no coinciden exactamente tambien puede debilitar o perder la señal. Compara PROM y PICO con y sin transmision; si empeora vuelve a 102. Cambiar el filtro borra las medidas angulares y detiene AUTO GUIADO. El barrido usa unos 270 kHz. Ningun filtro garantiza mas alcance ni elimina interferencias.
- Para orientar una antena direccional, elige un punto de referencia fijo como 0 grados, gira la antena hacia el angulo mostrado y espera al promedio. Se registra automaticamente ese angulo; con las flechas elige los demas, gira la antena y espera en cada uno. AUTO GUIADO inicia una vuelta de 0 a 315 grados en pasos de 45; en cada paso tienes 4 segundos para girar tu antena y despues debes mantenerla fija mientras mide. Cada resultado guiado promedia las muestras tomadas durante el periodo de medicion, no solo las ultimas cinco. No avanza ni gira la antena por si misma. PARAR AUTO detiene el recorrido; las flechas vuelven al modo manual.
- MAX REL requiere al menos dos medidas; si la diferencia total es inferior a 3 dB indica niveles similares en vez de elegir un angulo. Si no, señala el angulo relativo con mayor promedio registrado, no una direccion automatica. Para comparar, usa una señal continua y manten la placa y el punto de referencia en el mismo lugar; repite las medidas. BORRAR TODO inicia otra comparacion.
- IR: selecciona con las flechas Samsung TV, LG TV o Sony TV para probar los seis codigos de ejemplo; dependen del modelo. Hay diez espacios para TV, decodificador, DVD, barra de sonido, audio, proyector, ventilador y otros dispositivos; comienzan vacios y sirven para equipos con mando infrarrojo propio. TECLAS 1/2 o 2/2 cambia entre las 12 funciones.
- Para ensenar una tecla, selecciona un espacio, toca APRENDER, toca la funcion deseada y apunta el mando original al receptor; pulsa esa funcion antes de 20 segundos. El boton verde indica que se guardo en la memoria interna, que conserva los codigos al reiniciar. Repite para cada funcion. Para sustituir una tecla, vuelve a aprenderla; CANCELAR detiene el aprendizaje. Apunta el emisor al aparato y toca una tecla para probarla. ULTIMO IR reenvia la ultima señal simple y se pierde al reiniciar.
- El aprendizaje admite señales simples reconocidas NEC, Samsung, Sony, RC5, RC6, Panasonic, JVC, LG, Denon y Sharp de hasta 64 bits; otras señales, las desconocidas y estados largos de aire acondicionado no se guardan. Algunos equipos usan radio o Bluetooth, no IR; los codigos cambian por modelo y algunos mandos usan secuencias especiales o teclas mantenidas, por lo que no se garantiza compatibilidad. La placa no trae una biblioteca completa de marcas; usa el mando original para ensenar cada tecla.
- Si la pantalla tactil necesita calibracion, envia C por el puerto serie a 115200 baudios y toca las cuatro cruces.
- RADAR: coloca el emisor elegido exactamente a 1 metro de la placa y dejalo transmitiendo. Toca su fila entre las tres señales mas fuertes y espera 5 segundos sin moverlo. Si faltan muestras, repite. Solo ese emisor recibe distancia; 'sin ref' indica otro dispositivo. Guarda una referencia Wi-Fi y una BLE; calibrar otra de la misma clase reemplaza la anterior. Las referencias quedan guardadas.
- CLIMA: toca BUSCAR REDES Wi-Fi en la pantalla de la placa. Espera el escaneo, usa < y > si hay mas redes y toca la tuya de 2,4 GHz. Escribe la clave con el teclado tactil: abc/ABC/SIM cambia letras y simbolos; ESPACIO y BORRA editan. La clave es visible en dos lineas; toca OCULTAR para esconderla o MOSTRAR para comprobarla. Pulsa CONECTAR. Una red abierta conecta al tocarla. La clave distingue mayusculas. Para corregirla, busca la red y vuelve a introducirla. No necesitas el movil.
- El escaneo muestra hasta 30 redes visibles de 2,4 GHz; no permite introducir una red oculta desde la pantalla. La clave tactil admite caracteres ASCII imprimibles, de 8 a 63 para redes protegidas. Las redes de 5 GHz y los portales que requieren iniciar sesion en un navegador no estan soportados por este procedimiento. Si hay una red guardada, la placa inicia en CLIMA y trata de conectarse; tambien reintenta cada 30 segundos y al volver a CLIMA. Si no conecta, comprueba la clave y cobertura.
- La placa estima automaticamente las coordenadas por la direccion IP de Internet mediante ipwho.is; son aproximadas. Necesita acceso a Internet y hora sincronizada. Si falla, reintenta; puede usar la ultima ubicacion guardada. No hay GPS. La red y la ultima ubicacion se guardan en la memoria interna de la placa (flash NVS), sin tarjeta SD. Los ajustes anteriores de clima en la SD no se importan; introduce la red de nuevo. El resto de ajustes existentes, como calibracion tactil y del radar, permanece en la memoria interna; no se guardan registros de radar.
- La pantalla muestra DIA o NOCHE segun el clima recibido y una fase lunar aproximada calculada con la hora UTC; si faltan datos actuales muestra --. La ubicacion por IP puede ser incorrecta. El clima de Open-Meteo se consulta aproximadamente cada 15 minutos; si falla, reintenta en un minuto. Al salir de CLIMA se desconecta la red y se reanuda el radar Wi-Fi, tras terminar una consulta en curso. Toca OLVIDAR RED GUARDADA en CLIMA para borrar los datos de la memoria interna.
- No hay brujula ni sensor de orientacion; el angulo lo introduces tu girando la antena. El maximo puede ser ruido u otra señal, y los rebotes, obstaculos y señales intermitentes pueden falsear la direccion. No da posicion ni identifica drones.
- Las medidas y la frecuencia manual se pierden al reiniciar. El CC1101 solo mide mientras esta abierta la pantalla SUB-G; la actividad infrarroja puede interrumpirlo. La cascada no es un espectro simultaneo, recorre 21 o 41 posiciones sucesivamente, con una permanencia minima de 6 ms por posicion; la velocidad real varia y el barrido mas rapido puede perder mas pulsos cortos. El historial se borra al cambiar de frecuencia o salir de la vista. El barrido pasa poco tiempo en cada frecuencia; la actualizacion de pantalla tambien interrumpe el muestreo. El pico puede corresponder a ruido u otra señal y el umbral bajo aumenta falsas alertas. Para probar una radio UHF propia, selecciona su frecuencia y FIJO / DIR; compara las lecturas con y sin transmision, sin transmitir cerca de la placa. El CC1101 no recibe las bandas VHF habituales; a 462,600 MHz funciona cerca del limite superior especificado y la antena y el circuito pueden limitar la recepcion.
- Usa una antena compatible con el conector y la banda, sin forzar el conector. El filtro o la adaptacion del modulo pueden limitar las bandas practicas.
- La distancia sigue siendo una estimacion muy imprecisa, incluso calibrada: paredes, orientacion, potencia cambiante, multitrayecto y direcciones BLE privadas pueden alterar o invalidar la referencia. El radar muestra distancia radial segun fuerza de señal y angulos ficticios: no es ubicacion ni direccion real. Solo ve las tres señales mas fuertes.
- En CLIMA se pausa el radar Wi-Fi; BLE sigue intentando escanear, pero compartir radio y usar Internet puede reducir capturas. La simultaneidad con IR y SUB-G no esta garantizada. La pantalla es de 240 x 320 pixeles: usa dibujos integrados o cuadros BMP opcionales desde la tarjeta, no video HD. El reloj local depende de Internet y del desplazamiento horario devuelto por el servicio, que se actualiza con el clima.
- La clave Wi-Fi queda guardada sin cifrar en la memoria interna; alguien con acceso a la placa podria extraerla. Las copias antiguas en una tarjeta SD no se borran automaticamente. La clave aparece en la pantalla al escribirla, salvo que pulses OCULTAR; evita miradas ajenas y borra los datos antes de entregar el dispositivo. La ubicacion por IP puede corresponder a otra ciudad, especialmente con VPN o red movil. ipwho.is recibe tu direccion IP y Open-Meteo recibe las coordenadas estimadas.
- IR comparte pines con el tercer nRF24; los nRF24 estan desactivados. La simultaneidad de modulos necesita pruebas. El angulo del radar Wi-Fi y BLE es decorativo y la distancia muy aproximada.
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <SPI.h>
#include <Wire.h>
#include <SD.h>
#include <Adafruit_ILI9341.h>
#include <esp_wifi.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <math.h>
#include <ctype.h>
#include <IRremoteESP8266.h>
#include <IRrecv.h>
#include <IRsend.h>
#include <IRutils.h>
#include <XPT2046_Touchscreen.h>
#include <Preferences.h>
#include <RadioLib.h>
#include <esp_heap_caps.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <time.h>
#include "weather_ca.h"
#include "geo_ca.h"
// ============================================================
// CONSTANTES
// ============================================================
static constexpr int SCR_W = 240;
static constexpr int SCR_H = 320;
static constexpr int MAX_DEVICES = 48;
static constexpr int EVENT_QUEUE_LEN = 64;
// ============================================================
// ESTRUCTURAS
// ============================================================
struct Event {
uint8_t mac[6];
int8_t rssi;
uint8_t channel;
bool ble;
bool ap;
char name[33];
};
struct Entry {
Event e;
uint32_t seen;
bool used;
};
static QueueHandle_t events = nullptr;
static Entry devices[MAX_DEVICES];
static BLEScan* scanner = nullptr;
static volatile bool scanFinished = false;
static bool blePaused = false;
static bool wifiOk = false, bleOk = false;
// ============================================================
// PANTALLA — framebuffer 4bpp
// ============================================================
static Adafruit_ILI9341 tft(&SPI, 16, 17, 0);
class ScreenCanvas : public Adafruit_GFX {
public:
ScreenCanvas() : Adafruit_GFX(SCR_W, SCR_H) {}
bool allocate() {
buffer = static_cast<uint8_t*>(calloc(SCR_W * SCR_H / 2, 1));
return buffer != nullptr;
}
uint8_t* getBuffer() const { return buffer; }
bool waterfallColors = false;
void drawPixel(int16_t x, int16_t y, uint16_t color) override {
if (!buffer || x < 0 || y < 0 || x >= SCR_W || y >= SCR_H) return;
const unsigned n = (unsigned)y * SCR_W + x;
uint8_t index = colorIndex(color);
if (n & 1) buffer[n / 2] = (buffer[n / 2] & 0xf0) | index;
else buffer[n / 2] = (buffer[n / 2] & 0x0f) | (index << 4);
}
void packedLine(int x, int y, const uint8_t* line, int width) {
if (!buffer || x < 0 || y < 0 || y >= SCR_H || x + width > SCR_W ||
(x & 1) || (width & 1)) return;
memcpy(buffer + (y * SCR_W + x) / 2, line, width / 2);
}
uint16_t pixel(int x, int y) const {
if (!buffer || x < 0 || y < 0 || x >= SCR_W || y >= SCR_H) return 0;
unsigned n = (unsigned)y * SCR_W + x;
uint8_t pair = buffer[n / 2];
return palette[(n & 1) ? (pair & 0x0f) : (pair >> 4)];
}
void expandLine(int x, int y, int width, uint16_t* out) const {
// Only packed waterfall rows contain waterfall-palette indices.
// All other pixels, including the key, use the normal palette.
int left = 0, right = 0;
if (waterfallColors && y >= 161 && y < 225) { left = 10; right = 230; }
int start = max(x, left), end = min(x + width, right);
if (start >= end) {
expandRange((unsigned)y * SCR_W + x, width, out, palette);
return;
}
int before = start - x, middle = end - start;
expandRange((unsigned)y * SCR_W + x, before, out, palette);
expandRange((unsigned)y * SCR_W + start, middle, out + before, waterfallPalette);
expandRange((unsigned)y * SCR_W + end, x + width - end, out + before + middle, palette);
}
static constexpr uint16_t waterfallPalette[16] = {
0x0000, 0x0008, 0x0011, 0x001A, 0x003F, 0x019F, 0x02BF, 0x045F,
0x067F, 0x87FF, 0xFFFF, 0xFFE0, 0xFDC0, 0xFC00, 0xFA00, 0xF800
};
private:
void expandRange(unsigned n, int width, uint16_t* out, const uint16_t* colors) const {
if (width <= 0 || !buffer) return;
int i = 0;
if (n & 1) {
out[i++] = colors[buffer[n / 2] & 0x0f];
++n;
}
const uint8_t* src = buffer + n / 2;
while (i + 1 < width) {
uint8_t pair = *src++;
out[i++] = colors[pair >> 4];
out[i++] = colors[pair & 0x0f];
}
if (i < width) out[i] = colors[*src >> 4];
}
uint8_t* buffer = nullptr;
static constexpr uint16_t palette[16] = {
ILI9341_BLACK, 0x0004, 0x0028, 0x032A, 0x05D2, 0x0842,
0x0868, 0x10A4, 0x3186, 0x5D5D, ILI9341_LIGHTGREY,
ILI9341_WHITE, ILI9341_CYAN, ILI9341_GREEN,
ILI9341_YELLOW, ILI9341_RED
};
uint16_t previous = 0;
uint8_t previousIndex = 0;
uint8_t colorIndex(uint16_t c) {
if (c == previous) return previousIndex;
unsigned best = 0, distance = ~0U;
for (unsigned i = 0; i < 16; ++i) {
int dr = int((c >> 11) & 31) - int((palette[i] >> 11) & 31);
int dg = int((c >> 5) & 63) - int((palette[i] >> 5) & 63);
int db = int( c & 31) - int( palette[i] & 31);
unsigned d = 4 * dr * dr + dg * dg + 4 * db * db;
if (d < distance) { distance = d; best = i; }
}
previous = c; previousIndex = (uint8_t)best;
return previousIndex;
}
};
constexpr uint16_t ScreenCanvas::palette[16];
constexpr uint16_t ScreenCanvas::waterfallPalette[16];
static ScreenCanvas canvas;
static void flushRect(int x, int y, int w, int h) {
if (!canvas.getBuffer() || w <= 0 || h <= 0) return;
x = constrain(x, 0, SCR_W);
y = constrain(y, 0, SCR_H);
w = min(w, SCR_W - x);
h = min(h, SCR_H - y);
if (w <= 0 || h <= 0) return;
uint16_t line[SCR_W];
tft.startWrite();
tft.setAddrWindow(x, y, w, h);
for (int row = 0; row < h; ++row) {
canvas.expandLine(x, y + row, w, line);
tft.writePixels(line, w);
}
tft.endWrite();
}
// ============================================================
// RADIO SUB-GHZ
// ============================================================
static SPIClass radioSPI(HSPI);
static CC1101 subRadio = new Module(5, 6, RADIOLIB_NC, 3, radioSPI);
static const float subFreq[] = {315.00f, 433.92f, 868.35f, 915.00f};
static uint8_t subIndex = 1;
static float subCenter = 433.92f;
static uint8_t subMode = 0;
static char freqInput[9] = "";
static bool freqInvalid = false;
static uint8_t bearingSlot = 0;
static int8_t bearingValues[8];
static bool bearingValid[8] = {};
static bool bearingTuned = false;
// FIJO uses a narrower filter; the wide spectrum needs the original filter.
static bool subRxNarrow = false;
static bool bearingFilter58 = false; // Optional narrower filter for weak UHF signals.
static bool bearingAuto = false, bearingFrozen = false;
static uint32_t bearingAutoStart = 0;
static constexpr uint32_t BEARING_MOVE_MS = 4000, BEARING_MEASURE_MS = 1500;
static int bearingSamples[5] = {};
static uint8_t bearingCount = 0, bearingNext = 0;
static int32_t bearingAutoSum = 0;
static uint16_t bearingAutoCount = 0;
static uint32_t bearingLastSample = 0;
static int bearingAverage = -127;
static int bearingPeak = -127;
static uint32_t bearingPeakWindow = 0;
static bool subRange(float f, float& low, float& high) {
if (f >= 300.0f && f <= 348.0f) { low = 300; high = 348; return true; }
if (f >= 387.0f && f <= 464.0f) { low = 387; high = 464; return true; }
if (f >= 779.0f && f <= 928.0f) { low = 779; high = 928; return true; }
return false;
}
static bool subWide = true;
static float subSweepLow() { float lo, hi; subRange(subCenter, lo, hi); return max(lo, subCenter - (subWide ? 5.0f : 2.5f)); }
static float subSweepHigh() { float lo, hi; subRange(subCenter, lo, hi); return min(hi, subCenter + (subWide ? 5.0f : 2.5f)); }
static bool subOk = false;
static int subError = 0;
static constexpr uint8_t SUB_BINS_MAX = 41;
static uint8_t subBinCount() { return subWide ? 41 : 21; }
static const int subThresholds[] = {-100, -90, -80, -70, -60};
static uint8_t subThresholdIndex = 1;
static int subThreshold() { return subThresholds[subThresholdIndex]; }
static int8_t subBins[SUB_BINS_MAX], subWork[SUB_BINS_MAX];
static int subFloor = -60;
static constexpr int WATERFALL_RED_ABOVE_FLOOR = 12;
struct SubPeak { float freq; int8_t rssi; };
static SubPeak subHistory[3];
static uint8_t subHistoryCount = 0, subBin = 0;
static bool subWaiting = false, subHasSweep = false;
static uint32_t subTunedAt = 0, subRevision = 0, subSweepStarted = 0, subSweepMs = 0;
static int subBinMax = -127;
static uint8_t subSampleCount = 0;
static bool subBusPaused = false;
static int subPeak = -127;
static float subPeakFreq = 0;
// ============================================================
// WATERFALL — estado
// ============================================================
static constexpr uint8_t WATERFALL_ROWS = 64;
static constexpr int WATERFALL_WIDTH = 220;
static constexpr int WATERFALL_X = 10;
static constexpr int WATERFALL_Y = 161;
static constexpr int WF_DB_MIN = -110;
static constexpr int WF_DB_MAX = -35;
static constexpr int WF_DB_SPAN = WF_DB_MAX - WF_DB_MIN;
static constexpr int WF_COLD_MAX = 13;
static constexpr int WF_HOT_IDX = 14;
static constexpr int WF_ALERT_IDX = 15;
static constexpr int WF_RED_DB = 12;
struct WaterfallRow {
uint8_t pixels[WATERFALL_WIDTH / 2];
int8_t samples[SUB_BINS_MAX];
int8_t floorDb;
int8_t peakDb;
uint16_t peakBin;
uint32_t generation;
bool valid;
};
static WaterfallRow subWaterfall[WATERFALL_ROWS];
static uint8_t waterfallHead = 0;
static uint8_t waterfallCount = 0;
static uint32_t waterfallGeneration = 0;
static uint32_t subPreviousSweepFinished = 0;
enum WaterfallMode : uint8_t {
WF_MODE_ABSOLUTE = 0,
WF_MODE_RELATIVE = 1,
WF_MODE_DELTA = 2
};
static WaterfallMode wfMode = WF_MODE_RELATIVE;
static int8_t wfPeakHold[SUB_BINS_MAX], wfPeakReference[SUB_BINS_MAX];
static uint32_t wfPeakHoldAt[SUB_BINS_MAX];
static int8_t wfPreviousBins[SUB_BINS_MAX];
static bool wfPreviousValid = false;
static constexpr uint32_t WF_PEAK_HOLD_MS = 8000;
static constexpr int WF_PEAK_DECAY = 1;
static bool wfPaused = false;
static bool wfReplay = false;
static char wfSdMessage[40] = "SD: GUARDAR CARGAR";
static void clearWaterfall() {
memset(subWaterfall, 0, sizeof(subWaterfall));
waterfallHead = 0; waterfallCount = 0;
subPreviousSweepFinished = 0;
memset(wfPeakHold, -127, sizeof(wfPeakHold));
memset(wfPeakReference, -127, sizeof(wfPeakReference));
memset(wfPeakHoldAt, 0, sizeof(wfPeakHoldAt));
wfPreviousValid = false;
wfPaused = false;
++waterfallGeneration;
}
static uint8_t waterfallColorAbs(int dbm, int floorDb) {
int clamped = constrain(dbm, WF_DB_MIN, WF_DB_MAX);
int idx = 2 + (clamped - WF_DB_MIN) * (WF_COLD_MAX - 2) / WF_DB_SPAN;
idx = constrain(idx, 2, WF_COLD_MAX);
if (dbm - floorDb >= WF_RED_DB) return (uint8_t)WF_ALERT_IDX;
if (idx == WF_COLD_MAX) return (uint8_t)WF_HOT_IDX;
return (uint8_t)idx;
}
static uint8_t waterfallColorRel(int dbm, int floorDb) {
int delta = dbm - floorDb;
if (delta >= WF_RED_DB) return (uint8_t)WF_ALERT_IDX;
if (delta <= 0) return 1;
return (uint8_t)constrain(1 + delta, 2, WF_COLD_MAX);
}
static uint8_t waterfallColor(int dbm, int floorDb, int prevDb, bool hasPrev) {
switch (wfMode) {
case WF_MODE_ABSOLUTE:
return waterfallColorAbs(dbm, floorDb);
case WF_MODE_DELTA: {
if (!hasPrev) return waterfallColorAbs(dbm, floorDb);
int delta = dbm - prevDb;
if (delta >= 8) return (uint8_t)WF_ALERT_IDX;
if (delta >= 4) return (uint8_t)WF_HOT_IDX;
if (delta <= -4) return 1;
return waterfallColorAbs(dbm, floorDb);
}
case WF_MODE_RELATIVE:
default:
return waterfallColorRel(dbm, floorDb);
}
}
// ============================================================
// UTILIDADES SUB-GHZ
// ============================================================
static float subBinFreq(uint8_t bin) {
return subSweepLow() + bin * (subSweepHigh() - subSweepLow()) / (subBinCount() - 1);
}
static void resetBearingSamples() {
bearingCount = 0; bearingNext = 0; bearingLastSample = 0;
bearingAutoSum = 0; bearingAutoCount = 0;
bearingPeak = -127; bearingPeakWindow = millis();
}
static void clearBearings() {
memset(bearingValid, 0, sizeof(bearingValid));
resetBearingSamples();
}
static void resetSubSweep() {
memset(subBins, -127, sizeof(subBins));
memset(subWork, -127, sizeof(subWork));
subBin = 0; subWaiting = false; subHasSweep = false; subHistoryCount = 0;
clearWaterfall();
subPeak = -127; subPeakFreq = 0; subFloor = -60;
bearingTuned = false;
clearBearings();
++subRevision;
}
static void pushWaterfall() {
if (wfPaused) return;
waterfallHead = (waterfallHead + WATERFALL_ROWS - 1) % WATERFALL_ROWS;
WaterfallRow& row = subWaterfall[waterfallHead];
const int bins = subBinCount();
const bool hasPrev = wfPreviousValid;
memcpy(row.samples, subBins, bins);
row.floorDb = (int8_t)subFloor;
int peak = -127, peakBin = 0;
for (int i = 0; i < bins; ++i) {
if (subBins[i] > peak) { peak = subBins[i]; peakBin = i; }
}
row.peakDb = (int8_t)peak;
row.peakBin = (uint16_t)peakBin;
row.generation = ++waterfallGeneration;
row.valid = true;
for (int x = 0; x < WATERFALL_WIDTH; x += 2) {
int i0 = (x * (bins - 1) + (WATERFALL_WIDTH - 1) / 2) / (WATERFALL_WIDTH - 1);
int i1 = ((x + 1) * (bins - 1) + (WATERFALL_WIDTH - 1) / 2) / (WATERFALL_WIDTH - 1);
i0 = constrain(i0, 0, bins - 1);
i1 = constrain(i1, 0, bins - 1);
int prev0 = hasPrev ? wfPreviousBins[i0] : subBins[i0];
int prev1 = hasPrev ? wfPreviousBins[i1] : subBins[i1];
uint8_t c0 = waterfallColor(subBins[i0], row.floorDb, prev0, hasPrev);
uint8_t c1 = waterfallColor(subBins[i1], row.floorDb, prev1, hasPrev);
row.pixels[x / 2] = (uint8_t)((c0 << 4) | c1);
}
if (waterfallCount < WATERFALL_ROWS) ++waterfallCount;
// Compare dBm values, not the palette indices stored in the displayed row.
memcpy(wfPreviousBins, subBins, bins);
wfPreviousValid = true;
uint32_t now = millis();
for (int i = 0; i < bins; ++i) {
uint32_t age = now - wfPeakHoldAt[i];
uint32_t decay = age > WF_PEAK_HOLD_MS ? (age - WF_PEAK_HOLD_MS) / 1000 : 0;
int held = max(-127, (int)wfPeakReference[i] - (int)min<uint32_t>(decay * WF_PEAK_DECAY, 127));
if (subBins[i] >= held) {
wfPeakReference[i] = subBins[i];
wfPeakHoldAt[i] = now;
held = subBins[i];
}
wfPeakHold[i] = (int8_t)held;
}
++waterfallGeneration;
}
static void drawWaterfall() {
uint8_t empty[WATERFALL_WIDTH / 2];
memset(empty, 0x00, sizeof(empty));
for (int row = 0; row < WATERFALL_ROWS; ++row) {
const WaterfallRow& r = subWaterfall[(waterfallHead + row) % WATERFALL_ROWS];
const uint8_t* line = (row < waterfallCount && r.valid) ? r.pixels : empty;
canvas.packedLine(WATERFALL_X, WATERFALL_Y + row, line, WATERFALL_WIDTH);
}
}
static void drawWaterfallPeakMarks() {
const int bins = subBinCount();
if (bins <= 1 || waterfallCount == 0) return;
const int rows = min<int>(waterfallCount, 8);
for (int row = 0; row < rows; ++row) {
const WaterfallRow& r = subWaterfall[(waterfallHead + row) % WATERFALL_ROWS];
if (!r.valid) continue;
int px = WATERFALL_X + (int)r.peakBin * (WATERFALL_WIDTH - 1) / (bins - 1);
px = constrain(px, WATERFALL_X, WATERFALL_X + WATERFALL_WIDTH - 1);
uint16_t color = (row == 0) ? ILI9341_CYAN : 0x05D2;
tft.writePixel(px, WATERFALL_Y + row, color);
}
tft.drawFastVLine(WATERFALL_X - 3, WATERFALL_Y, 1, ILI9341_CYAN);
}
static void drawWaterfallKey() {
tft.fillRect(10, 50, 220, 15, 0x0004);
const int barY = 52, barH = 10;
for (int x = 0; x < 220; ++x) {
int dbm = WF_DB_MIN + x * WF_DB_SPAN / 219;
uint8_t idx;
switch (wfMode) {
case WF_MODE_ABSOLUTE:
case WF_MODE_DELTA:
idx = waterfallColorAbs(dbm, -60);
break;
default:
idx = waterfallColorRel(subFloor + (dbm - WF_DB_MIN) / 6, subFloor);
break;
}
tft.drawFastVLine(10 + x, barY, barH, ScreenCanvas::waterfallPalette[idx]);
}
const int labels[] = {WF_DB_MIN, -90, -70, -50, WF_DB_MAX};
const int lx[] = {10, 55, 110, 165, 208};
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY);
for (int i = 0; i < 5; ++i) {
tft.setCursor(lx[i], 63);
tft.printf("%d", labels[i]);
}
const char* modeName = (wfMode == WF_MODE_ABSOLUTE) ? "ABS"
: (wfMode == WF_MODE_DELTA) ? "DELTA"
: "REL";
tft.setTextColor(ILI9341_CYAN);
tft.setCursor(190, 63);
tft.print(modeName);
if (wfPaused) {
tft.fillRoundRect(10, 50, 40, 12, 3, ILI9341_YELLOW);
tft.setTextColor(ILI9341_BLACK);
tft.setCursor(15, 52);
tft.print("PAUSA");
}
}
// ============================================================
// IR
// ============================================================
static IRrecv irReceiver(21);
static IRsend irTransmitter(14);
static decode_type_t savedType = UNKNOWN;
static uint64_t savedValue = 0;
static uint16_t savedBits = 0;
static decode_results irResult;
static char irLine[50] = "IR: esperando senal";
// ============================================================
// UI / ESTADO GLOBAL
// ============================================================
static char displaySnapshot[512] = {};
static XPT2046_Touchscreen touch(18);
static Preferences prefs;
static uint8_t page = 0;
static bool calibrating = false, touchHeld = false;
static uint8_t profile = 0, calStep = 0;
static int16_t calX[4], calY[4];
static bool calibrationValid = false;
// Samsung and LG retain the original examples; Sony uses RMT-TX100D SIRC commands.
static const char* const profiles[] = {"Samsung TV", "LG TV", "Sony TV", "TV 1", "TV 2", "DECODIFICADOR", "DVD / BLURAY", "BARRA SONIDO", "EQUIPO AUDIO", "PROYECTOR", "VENTILADOR", "OTRO 1", "OTRO 2", "Ultimo IR"};
static constexpr int IR_CUSTOM_FIRST = 3, IR_CUSTOM_COUNT = 10;
static constexpr int IR_LAST = IR_CUSTOM_FIRST + IR_CUSTOM_COUNT;
static constexpr int IR_KEYS = 12;
static const char* const actions[] = {"POWER", "MUTE", "VOL -", "VOL +", "CH -", "CH +", "ARRIBA", "ABAJO", "IZQUIERDA", "DERECHA", "OK", "VOLVER"};
struct IrKey { uint64_t value; int32_t type; uint16_t bits; uint16_t version; };
static IrKey irKeys[IR_CUSTOM_COUNT][IR_KEYS] = {};
static uint8_t irKeyPage = 0;
// -1 normal, -2 select key, >=0 waiting for signal.
static int8_t irLearning = -1;
static uint32_t irLearnStart = 0;
static const uint32_t codes[2][6] = {
{0xE0E040BF, 0xE0E0F00F, 0xE0E0D02F, 0xE0E0E01F, 0xE0E008F7, 0xE0E048B7},
{0x20DF10EF, 0x20DF906F, 0x20DFC03F, 0x20DF40BF, 0x20DF807F, 0x20DF00FF}
};
static const int targetX[4] = {20, 219, 219, 20};
static const int targetY[4] = {20, 20, 299, 299};
static void invalidateScreen() { displaySnapshot[0] = 0; }
// The IP5306 reports coarse levels, not measured battery voltage.
static int batteryLevel = -1;
static uint32_t batteryReadAt = 0;
static bool batteryFirstRead = true;
static int sdResult = 0; // 0 untested, 1 FAT mounted, 2 failed
static uint64_t sdMiB = 0;
static char sdFiles[3][34] = {};
static void readBatteryLevel() {
int level = -1;
Wire.beginTransmission(0x75);
Wire.write((uint8_t)0x78);
if (Wire.endTransmission(false) == 0 && Wire.requestFrom(0x75, 1) == 1) {
int raw = Wire.read();
if (raw != 0xff) {
switch (raw & 0xf0) {
case 0xe0: level = 25; break;
case 0xc0: level = 50; break;
case 0x80: level = 75; break;
case 0x00: level = 100; break;
}
}
}
if (level != batteryLevel) { batteryLevel = level; if (page == 4) invalidateScreen(); }
}
static void testSdCard() {
// SUB-G is inactive on this page. Deselect all slaves on the shared bus.
for (int pin : {5, 4, 48, 10}) digitalWrite(pin, HIGH);
sdMiB = 0;
memset(sdFiles, 0, sizeof(sdFiles));
if (SD.begin(10, radioSPI, 4000000)) {
sdResult = 1;
sdMiB = SD.cardSize() / (1024ULL * 1024ULL);
File root = SD.open("/");
if (root && root.isDirectory()) {
for (int i = 0; i < 3; ++i) {
File entry = root.openNextFile();
if (!entry) break;
snprintf(sdFiles[i], sizeof(sdFiles[i]), "%s%s", entry.name(), entry.isDirectory() ? "/" : "");
entry.close();
}
root.close();
}
SD.end();
Serial.printf("microSD: volumen FAT montado, %llu MiB\n", (unsigned long long)sdMiB);
} else {
sdResult = 2;
SD.end();
Serial.println("microSD: no se pudo montar; revisa tarjeta, FAT y contactos");
}
digitalWrite(10, HIGH);
invalidateScreen();
}
// Temporary mounts only; CC1101 and the card share radioSPI.
static bool openCard() {
for (int pin : {5, 4, 48, 10}) digitalWrite(pin, HIGH);
if (SD.begin(10, radioSPI, 4000000)) return true;
SD.end(); digitalWrite(10, HIGH); return false;
}
static void closeCard() { SD.end(); digitalWrite(10, HIGH); }
// A bounded text format, not HackRF IQ or hackrf_sweep CSV.
static bool readTextLine(File &f, char *out, size_t cap) {
size_t n = 0; bool ended = false;
while (f.available()) {
int c = f.read();
if (c == '\n') { ended = true; break; }
if (c == '\r') continue;
if (c < 32 || c > 126 || n + 1 >= cap) return false;
out[n++] = (char)c;
}
out[n] = 0;
return n > 0 && (ended || !f.available());
}
static bool parseWfSamples(char *line, int8_t *values, int bins) {
char *p = line;
for (int i = 0; i < bins; ++i) {
char *end; long value = strtol(p, &end, 10);
if (end == p || value < -127 || value > 0) return false;
if (i + 1 < bins) { if (*end != ',') return false; p = end + 1; }
else if (*end) return false;
values[i] = (int8_t)value;
}
return true;
}
static void saveWaterfallSd() {
if (wfReplay || !waterfallCount) { strlcpy(wfSdMessage, "SD: SIN BARRIDOS EN VIVO", sizeof(wfSdMessage)); return; }
if (!openCard()) { strlcpy(wfSdMessage, "SD: NO SE PUDO MONTAR", sizeof(wfSdMessage)); return; }
if (SD.exists("/cascada.txt")) {
strlcpy(wfSdMessage, "SD: CASCADA.TXT YA EXISTE", sizeof(wfSdMessage));
closeCard(); return;
}
File f = SD.open("/cascada.txt", FILE_WRITE);
bool ok = !!f;
if (ok) ok = f.printf("SENTINELA1,%.3f,%d,%d\n", subCenter, subWide ? 1 : 0, subBinCount()) > 0;
for (int r = waterfallCount - 1; ok && r >= 0; --r) {
WaterfallRow &row = subWaterfall[(waterfallHead + r) % WATERFALL_ROWS];
for (int i = 0; i < subBinCount() && ok; ++i)
ok = f.printf(i ? ",%d" : "%d", (int)row.samples[i]) > 0;
if (ok) ok = f.print('\n') == 1;
}
if (f) { f.flush(); f.close(); }
if (!ok) SD.remove("/cascada.txt");
closeCard();
strlcpy(wfSdMessage, ok ? "SD: GUARDADO CASCADA.TXT" : "SD: ERROR AL GUARDAR", sizeof(wfSdMessage));
}
static void loadWaterfallSd() {
if (!openCard()) { strlcpy(wfSdMessage, "SD: NO SE PUDO MONTAR", sizeof(wfSdMessage)); return; }
File f = SD.open("/cascada.txt", FILE_READ);
char line[256]; float center = 0, lo, hi; int wide = -1, bins = 0, used = 0;
int8_t (*rows)[SUB_BINS_MAX] = (int8_t (*)[SUB_BINS_MAX])malloc(WATERFALL_ROWS * SUB_BINS_MAX);
bool ok = f && rows && f.size() <= 16000 && readTextLine(f, line, sizeof(line));
if (ok) ok = sscanf(line, "SENTINELA1,%f,%d,%d%n", ¢er, &wide, &bins, &used) == 3 && line[used] == 0 &&
isfinite(center) && subRange(center, lo, hi) && (wide == 0 || wide == 1) && bins == (wide ? 41 : 21);
int count = 0;
while (ok && f.available()) {
if (count >= WATERFALL_ROWS || !readTextLine(f, line, sizeof(line)) || !parseWfSamples(line, rows[count], bins)) { ok = false; break; }
++count;
}
if (f) f.close(); closeCard();
if (!ok || !count) { free(rows); strlcpy(wfSdMessage, "SD: TXT AUSENTE O INVALIDO", sizeof(wfSdMessage)); return; }
subCenter = center; subWide = wide; resetSubSweep(); wfReplay = true;
for (int r = 0; r < count; ++r) {
memcpy(subBins, rows[r], bins);
int8_t sorted[SUB_BINS_MAX]; memcpy(sorted, subBins, bins);
for (int i = 1; i < bins; ++i) { int8_t v = sorted[i]; int j = i - 1; while (j >= 0 && sorted[j] > v) { sorted[j+1] = sorted[j]; --j; } sorted[j+1] = v; }
subFloor = sorted[bins / 2]; pushWaterfall();
}
free(rows);
int best = 0; for (int i = 1; i < bins; ++i) if (subBins[i] > subBins[best]) best = i;
subPeak = subBins[best]; subPeakFreq = subBinFreq(best); subHasSweep = true; subSweepMs = 0;
strlcpy(wfSdMessage, "SD: REPRODUCCION / EN VIVO", sizeof(wfSdMessage));
}
// ============================================================
// BARRIDO SUB-GHZ (cooperativo)
// ============================================================
static void serviceSubSweep(uint32_t now) {
if (wfReplay) return;
if (page != 2 || !subOk || calibrating || subMode == 1) return;
if (digitalRead(21) != HIGH) {
if (!subBusPaused) {
subBusPaused = true; subWaiting = false; subBin = 0; subHasSweep = false;
bearingTuned = false; clearWaterfall(); resetBearingSamples(); ++subRevision;
}
return;
}
if (subBusPaused) {
subBusPaused = false;
if (bearingAuto) bearingAutoStart = now;
++subRevision;
}
if (subMode == 2) {
if (!bearingTuned) {
subError = subRadio.setRxBandwidth(bearingFilter58 ? 58.0f : 102.0f);
if (!subError) subRxNarrow = true;
if (!subError) subError = subRadio.setFrequency(subCenter);
if (!subError) subError = subRadio.receiveDirectAsync();
if (subError) { subOk = false; ++subRevision; return; }
bearingTuned = true; resetBearingSamples(); bearingLastSample = millis();
return;
}
if (bearingAuto) {
uint32_t elapsed = now - bearingAutoStart;
if (elapsed < BEARING_MOVE_MS) return;
if (elapsed >= BEARING_MOVE_MS + BEARING_MEASURE_MS && bearingAutoCount >= 5) {
bearingValues[bearingSlot] = bearingAutoSum / bearingAutoCount;
bearingValid[bearingSlot] = true;
if (bearingSlot == 7) { bearingAuto = false; bearingFrozen = true; }
else { ++bearingSlot; bearingAutoStart = now; resetBearingSamples(); }
++subRevision;
return;
}
}
int rssi = constrain((int)roundf(subRadio.getRSSI()), -127, 0);
if (now - bearingPeakWindow >= 5000) { bearingPeakWindow = now; bearingPeak = -127; ++subRevision; }
if (rssi > bearingPeak) { bearingPeak = rssi; ++subRevision; }
if (now - bearingLastSample < 100) return;
bearingLastSample = now;
int previousAverage = bearingAverage;
uint8_t previousCount = bearingCount;
if (bearingAuto) { bearingAutoSum += rssi; ++bearingAutoCount; }
bearingSamples[bearingNext] = rssi;
bearingNext = (bearingNext + 1) % 5;
if (bearingCount < 5) ++bearingCount;
int sum = 0; for (int i = 0; i < bearingCount; ++i) sum += bearingSamples[i];
bearingAverage = sum / bearingCount;
if (!bearingAuto && !bearingFrozen && bearingCount == 5 &&
(!bearingValid[bearingSlot] || bearingValues[bearingSlot] != bearingAverage)) {
bearingValues[bearingSlot] = bearingAverage;
bearingValid[bearingSlot] = true;
++subRevision;
}
if ((bearingCount == 5 && previousCount < 5) ||
(bearingCount == 5 && bearingAverage != previousAverage)) ++subRevision;
return;
}
if (!subWaiting) {
if (subRxNarrow) {
subError = subRadio.setRxBandwidth(270.0f);
if (subError) { subOk = false; ++subRevision; return; }
subRxNarrow = false;
}
if (subBin == 0) subSweepStarted = millis();
subError = subRadio.setFrequency(subBinFreq(subBin));
if (!subError) subError = subRadio.receiveDirectAsync();
if (subError) { subOk = false; ++subRevision; return; }
subTunedAt = millis(); subBinMax = -127; subSampleCount = 0; subWaiting = true;
return;
}
if (now - subTunedAt < 3) return;
int rssi = constrain((int)roundf(subRadio.getRSSI()), -127, 0);
subBinMax = max(subBinMax, rssi);
if (subSampleCount < 2) ++subSampleCount;
if (now - subTunedAt < 6 || subSampleCount < 2) return;
subWork[subBin] = subBinMax;
subWaiting = false;
if (++subBin != subBinCount()) return;
subBin = 0;
uint32_t finished = millis();
subSweepMs = finished - (subPreviousSweepFinished ? subPreviousSweepFinished : subSweepStarted);
subPreviousSweepFinished = finished;
memcpy(subBins, subWork, sizeof(subBins));
int8_t sorted[SUB_BINS_MAX];
memcpy(sorted, subBins, subBinCount());
for (uint8_t i = 1; i < subBinCount(); ++i) {
int8_t value = sorted[i]; int j = i - 1;
while (j >= 0 && sorted[j] > value) { sorted[j + 1] = sorted[j]; --j; }
sorted[j + 1] = value;
}
subFloor = sorted[subBinCount() / 2];
pushWaterfall();
uint8_t best = 0;
for (uint8_t i = 1; i < subBinCount(); ++i)
if (subBins[i] > subBins[best]) best = i;
subPeak = subBins[best]; subPeakFreq = subBinFreq(best); subHasSweep = true;
if (subPeak >= subThreshold()) {
if (subHistoryCount && fabsf(subHistory[0].freq - subPeakFreq) < 0.01f) {
subHistory[0].rssi = max((int)subHistory[0].rssi, subPeak);
} else {
for (int i = 2; i > 0; --i) subHistory[i] = subHistory[i - 1];
subHistory[0] = {subPeakFreq, (int8_t)subPeak};
if (subHistoryCount < 3) ++subHistoryCount;
}
}
++subRevision;
}
// ============================================================
// WI-FI / BLE
// ============================================================
static void cleanName(char* out, const char* in, size_t n) {
size_t j = 0;
for (size_t i = 0; i < n && j < 32; ++i) {
unsigned char c = (unsigned char)in[i];
out[j++] = (c >= 32 && c <= 126 && c != '|') ? c : '?';
}
out[j] = 0;
}
static void wifiPacket(void* buf, wifi_promiscuous_pkt_type_t type) {
if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return;
const wifi_promiscuous_pkt_t* pkt = (const wifi_promiscuous_pkt_t*)buf;
if (pkt->rx_ctrl.sig_len < 28 || pkt->rx_ctrl.rx_state != 0) return;
size_t len = pkt->rx_ctrl.sig_len - 4;
const uint8_t* p = pkt->payload;
if ((p[0] & 0x0c) != (type == WIFI_PKT_MGMT ? 0x00 : 0x08)) return;
Event e = {};
memcpy(e.mac, p + 10, 6);
if (e.mac[0] & 1) return;
e.rssi = pkt->rx_ctrl.rssi;
e.channel = pkt->rx_ctrl.channel;
uint8_t subtype = p[0] & 0xf0;
if (type == WIFI_PKT_MGMT && (subtype == 0x80 || subtype == 0x50)) {
e.ap = true;
if (len >= 38 && p[36] == 0 && p[37] <= 32 && 38U + p[37] <= len)
cleanName(e.name, (const char*)(p + 38), p[37]);
}
xQueueSend(events, &e, 0);
}
class Advertisements : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice device) override {
Event e = {};
e.ble = true;
String a = device.getAddress().toString();
unsigned int b[6];
if (sscanf(a.c_str(), "%x:%x:%x:%x:%x:%x",
&b[0], &b[1], &b[2], &b[3], &b[4], &b[5]) != 6) return;
for (int i = 0; i < 6; ++i) e.mac[i] = (uint8_t)b[i];
e.rssi = device.getRSSI();
if (device.haveName()) {
String name = device.getName();
cleanName(e.name, name.c_str(), name.length());
}
xQueueSend(events, &e, 0);
}
};
static Advertisements advertisements;
static void onScanEnd(BLEScanResults) { scanFinished = true; }
static void startBleRadar() {
BLEDevice::init("");
scanner = BLEDevice::getScan();
scanFinished = false; blePaused = false;
if (scanner) {
scanner->setAdvertisedDeviceCallbacks(&advertisements, true);
scanner->setActiveScan(false);
scanner->setInterval(100);
scanner->setWindow(45);
bleOk = scanner->start(5, onScanEnd, false);
} else {
bleOk = false;
Serial.println("ERROR BLE: no se pudo iniciar; continua sin Bluetooth");
}
}
// ============================================================
// CLASIFICACIÓN / MEMORIA / REPORTE
// ============================================================
static const char* guess(const Event& e) {
char lower[33];
for (size_t i = 0; i < sizeof(lower); ++i)
lower[i] = (char)tolower((unsigned char)e.name[i]);
if (strstr(lower, "drone") || strstr(lower, "mavic") ||
strstr(lower, "phantom") || strstr(lower, "dji")) return "posible dron";
if (strstr(lower, "camera") || strstr(lower, "camara") ||
strstr(lower, "gopro") || strstr(lower, "ipcam")) return "posible camara";
if (strstr(lower, "iphone") || strstr(lower, "android") ||
strstr(lower, "galaxy") || strstr(lower, "phone")) return "posible movil";
return e.ap ? "punto Wi-Fi; tipo desconocido" : "desconocido";
}
// Each calibration applies only to its own transmitter.
struct RssiReference { uint8_t mac[6]; int8_t rssi; bool valid; };
static RssiReference references[2] = {};
static bool rssiCalActive = false;
static Event rssiCalTarget = {};
static uint32_t rssiCalStart = 0;
static int rssiCalSum = 0, rssiCalCount = 0;
static Event radarShown[3] = {};
static bool radarShownValid[3] = {};
static char rssiCalMessage[40] = "Toca una senal para calibrar";
static float estimateMeters(const Event& e) {
const RssiReference& r = references[e.ble ? 1 : 0];
if (!r.valid || memcmp(r.mac, e.mac, 6)) return -1;
return constrain(powf(10.0f, (r.rssi - e.rssi) / 20.0f), 0.1f, 999.0f);
}
static void serviceRssiCalibration(uint32_t now) {
if (!rssiCalActive || now - rssiCalStart < 5000) return;
rssiCalActive = false;
if (rssiCalCount < 5) {
strlcpy(rssiCalMessage, "Sin muestras: vuelve a intentar", sizeof(rssiCalMessage));
} else {
int i = rssiCalTarget.ble ? 1 : 0;
memcpy(references[i].mac, rssiCalTarget.mac, 6);
references[i].rssi = (int8_t)roundf((float)rssiCalSum / rssiCalCount);
references[i].valid = true;
prefs.begin("sentinela", false);
prefs.putBytes(i ? "refBle" : "refWifi", &references[i], sizeof(RssiReference));
prefs.end();
snprintf(rssiCalMessage, sizeof(rssiCalMessage), "Calibrado: %d dBm a 1m", references[i].rssi);
}
invalidateScreen();
}
static void remember(const Event& e) {
if (rssiCalActive && e.ble == rssiCalTarget.ble && !memcmp(e.mac, rssiCalTarget.mac, 6)) {
rssiCalSum += e.rssi; ++rssiCalCount;
}
uint32_t now = millis();
int slot = -1, oldest = 0;
uint32_t maxAge = 0;
for (int i = 0; i < MAX_DEVICES; ++i) {
if (devices[i].used && devices[i].e.ble == e.ble &&
memcmp(devices[i].e.mac, e.mac, 6) == 0) { slot = i; break; }
if (!devices[i].used && slot < 0) slot = i;
uint32_t age = now - devices[i].seen;
if (age >= maxAge) { maxAge = age; oldest = i; }
}
if (slot < 0) slot = oldest;
Entry& d = devices[slot];
bool same = d.used && d.e.ble == e.ble && memcmp(d.e.mac, e.mac, 6) == 0;
if (!same) d.e = e;
else {
d.e.rssi = e.rssi;
if (e.channel) d.e.channel = e.channel;
if (e.ap) d.e.ap = true;
if (e.name[0]) memcpy(d.e.name, e.name, sizeof(e.name));
}
d.seen = now;
d.used = true;
}
static void report() {
static int index = -1, count = 0;
static uint32_t lastStart = 0;
uint32_t now = millis();
if (index < 0) {
if (now - lastStart < 5000 || Serial.availableForWrite() < 80) return;
lastStart = now; index = 0; count = 0;
Serial.println("\n--- SENTINELA | ultimos 30 s | distancia solo con referencia propia ---");
if (!wifiOk) Serial.println("ERROR Wi-Fi");
if (!bleOk) Serial.println("ERROR BLE");
return;
}
if (Serial.availableForWrite() < 100) return;
while (index < MAX_DEVICES) {
Entry& d = devices[index++];
if (!d.used || now - d.seen > 30000) { d.used = false; continue; }
const Event& e = d.e;
float meters = estimateMeters(e);
char range[20]; if (meters < 0) strlcpy(range, "sin ref", sizeof(range)); else snprintf(range, sizeof(range), "~%.1fm", meters);
Serial.printf("%s | %02X:%02X:%02X:%02X:%02X:%02X | %s | %s | %d dBm | %s | %lus | ch %u\n",
e.ble ? "BLE" : "WiFi",
e.mac[0], e.mac[1], e.mac[2], e.mac[3], e.mac[4], e.mac[5],
guess(e), e.name[0] ? e.name : "sin nombre", e.rssi, range,
(unsigned long)((now - d.seen) / 1000), e.channel);
++count;
return;
}
if (!count) Serial.println("Sin senales recientes.");
index = -1;
}
// ============================================================
// CALIBRACIÓN TÁCTIL
// ============================================================
static long determinant() {
long ux = ((long)calX[1] + calX[2] - calX[0] - calX[3]) / 2;
long uy = ((long)calY[1] + calY[2] - calY[0] - calY[3]) / 2;
long vx = ((long)calX[2] + calX[3] - calX[0] - calX[1]) / 2;
long vy = ((long)calY[2] + calY[3] - calY[0] - calY[1]) / 2;
return ux * vy - uy * vx;
}
static void showCalibration() {
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE);
tft.setCursor(45, 140); tft.print("CALIBRAR PANTALLA");
tft.setCursor(39, 155); tft.print("Toca cada cruz y suelta");
tft.setCursor(75, 171); tft.printf("Punto %u de 4", calStep + 1);
int x = targetX[calStep], y = targetY[calStep];
tft.drawCircle(x, y, 9, ILI9341_CYAN);
tft.drawFastHLine(x - 14, y, 29, ILI9341_CYAN);
tft.drawFastVLine(x, y - 14, 29, ILI9341_CYAN);
}
static void startCalibration() {
calibrating = true; calStep = 0; touchHeld = true;
showCalibration();
}
static void saveCalibration() {
calibrationValid = labs(determinant()) > 100000L;
if (calibrationValid) {
prefs.begin("sentinela", false);
prefs.putBytes("cx", calX, sizeof(calX));
prefs.putBytes("cy", calY, sizeof(calY));
prefs.end();
calibrating = false; invalidateScreen();
Serial.println("Pantalla calibrada y guardada.");
} else {
Serial.println("Calibracion invalida, toca las cuatro cruces de nuevo.");
calStep = 0; showCalibration();
}
}
static bool touchPosition(const TS_Point& p, int& x, int& y) {
long det = determinant();
if (!calibrationValid || labs(det) <= 100000L) return false;
float ux = ((long)calX[1] + calX[2] - calX[0] - calX[3]) / 2.0f;
float uy = ((long)calY[1] + calY[2] - calY[0] - calY[3]) / 2.0f;
float vx = ((long)calX[2] + calX[3] - calX[0] - calX[1]) / 2.0f;
float vy = ((long)calY[2] + calY[3] - calY[0] - calY[1]) / 2.0f;
float originX = (calX[0] + calX[1] + calX[2] + calX[3]) / 4.0f - (ux + vx) / 2;
float originY = (calY[0] + calY[1] + calY[2] + calY[3]) / 4.0f - (uy + vy) / 2;
float dx = p.x - originX, dy = p.y - originY;
float d = ux * vy - uy * vx;
x = constrain((int)(20 + 199 * (dx * vy - dy * vx) / d), 0, SCR_W - 1);
y = constrain((int)(20 + 279 * (dy * ux - dx * uy) / d), 0, SCR_H - 1);
return true;
}
// ============================================================
// IR — envío
// ============================================================
static void sendIR(decode_type_t type, uint64_t value, uint16_t bits) {
irReceiver.disableIRIn();
bool sent = irTransmitter.send(type, value, bits);
irReceiver.enableIRIn(true);
snprintf(irLine, sizeof(irLine), "%s", sent ? "IR: enviado" : "IR: no compatible");
Serial.println(irLine);
invalidateScreen();
}
static bool supportedLearnProtocol(decode_type_t type) {
switch (type) {
case NEC: case SAMSUNG: case SONY: case RC5: case RC6:
case PANASONIC: case JVC: case LG: case DENON: case SHARP:
return true;
default: return false;
}
}
static void irKeyName(char* out, int device, int key) {
snprintf(out, 12, "p%02dk%02d", device, key);
}
static void loadIrKeys() {
Preferences storage;
if (!storage.begin("ir-remotes", true)) return;
for (int d = 0; d < IR_CUSTOM_COUNT; ++d) for (int k = 0; k < IR_KEYS; ++k) {
char name[12]; irKeyName(name, d, k);
IrKey item = {};
if (storage.isKey(name) && storage.getBytesLength(name) == sizeof(item) &&
storage.getBytes(name, &item, sizeof(item)) == sizeof(item) &&
item.version == 1 && item.bits > 0 && item.bits <= 64 &&
supportedLearnProtocol((decode_type_t)item.type)) irKeys[d][k] = item;
}
storage.end();
}
static bool saveIrKey(int device, int key, const decode_results& result) {
IrKey item = {};
item.value = result.value; item.type = (int32_t)result.decode_type;
item.bits = result.bits; item.version = 1;
char name[12]; irKeyName(name, device, key);
Preferences storage;
if (!storage.begin("ir-remotes", false)) return false;
bool ok = storage.putBytes(name, &item, sizeof(item)) == sizeof(item);
storage.end();
if (ok) irKeys[device][key] = item;
return ok;
}
static void sendAction(int action) {
if (profile == IR_LAST) {
if (savedBits) sendIR(savedType, savedValue, savedBits);
else { strlcpy(irLine, "IR: recibe un mando primero", sizeof(irLine)); invalidateScreen(); }
} else if (profile < IR_CUSTOM_FIRST) {
if (action < 6) {
if (profile == 2) {
// Sony RMT-TX100D, SIRC 12-bit TV address 1.
static const uint8_t sonyCommands[] = {0x15, 0x14, 0x13, 0x12, 0x11, 0x10};
sendIR(SONY, irTransmitter.encodeSony(12, sonyCommands[action], 1), 12);
} else sendIR(profile == 0 ? SAMSUNG : NEC, codes[profile][action], 32);
}
} else {
const IrKey& key = irKeys[profile - IR_CUSTOM_FIRST][action];
if (key.version == 1) sendIR((decode_type_t)key.type, key.value, key.bits);
else { strlcpy(irLine, "Vacio: toca APRENDER", sizeof(irLine)); invalidateScreen(); }
}
}
// Weather setup is performed on the device.
static bool weatherMode = false, weatherWorker = false, weatherConfigured = false, weatherFailed = false;
static uint32_t weatherGeneration = 0;
static String netName, netPass;
static float weatherLat = 0, weatherLon = 0;
static uint32_t weatherLastAttempt = 0, weatherUpdated = 0;
static bool haveWeather = false, haveOffset = false;
static bool clock12Hour = false, temperatureF = false;
static bool haveLocation = false, locatePending = true, locationAttempted = false;
static char weatherCity[28] = {};
static int weatherCode = -1, weatherOffset = 0, weatherIsDay = -1;
static float weatherTemp = 0, weatherWind = 0;
static QueueHandle_t weatherQueue = nullptr;
struct WeatherResult { bool ok, locationOk, locationAttempted; float temp, wind, lat, lon; int code, offset, isDay; uint32_t generation; char city[28]; };
struct WeatherRequest { float lat, lon; bool locate, haveLocation; uint32_t generation; };
#include "weather_storage.h"
// On-device setup keeps a private copy of scan results.
struct LocalNetwork { String name; int32_t rssi; bool open; };
static LocalNetwork localNetworks[30];
static int localCount = 0, localOffset = 0;
static uint8_t localView = 0, keyPage = 0;
static bool localScanPending = false, localScanning = false, localScanError = false, passwordVisible = true;
static String chosenName, typedPassword, localMessage;
static uint32_t localRevision = 0, connectionStarted = 0, lastConnectionAttempt = 0;
static const char* const keyRows[3][4] = {
{"1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"},
{"1234567890", "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"},
{"!@#$%^&*()", "-_=+[]{}\\|", ";:'\",.<>/?", "`~"}
};
static void cancelLocalScan() {
if (localScanning) { esp_wifi_scan_stop(); WiFi.scanDelete(); }
localScanning = localScanPending = false;
}
static void beginLocalScan() {
cancelLocalScan(); localCount = localOffset = 0; localScanError = false;
localScanPending = true; localMessage = ""; ++localRevision;
}
static void serviceLocalScan() {
if (page != 3 || localView != 1 || !weatherMode) return;
if (localScanPending && !weatherWorker) {
WiFi.scanDelete(); int result = WiFi.scanNetworks(true, false);
localScanPending = false; localScanning = result == WIFI_SCAN_RUNNING;
localScanError = !localScanning; ++localRevision;
}
if (!localScanning) return;
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) return;
localScanning = false; localScanError = n < 0;
for (int i = 0; i < n && localCount < 30; ++i) {
String name = WiFi.SSID(i);
if (!name.length()) continue;
localNetworks[localCount++] = {name, WiFi.RSSI(i), WiFi.encryptionType(i) == WIFI_AUTH_OPEN};
}
WiFi.scanDelete(); ++localRevision;
}
static bool connectLocalNetwork() {
if (!chosenName.length() || chosenName.length() > 32 || typedPassword.length() > 63 ||
(typedPassword.length() && typedPassword.length() < 8)) {
localMessage = "Clave: 8 a 63 caracteres"; ++localRevision; return false;
}
if (!saveWeatherConfig(chosenName, typedPassword, 0, 0, false)) {
localMessage = "Error al guardar en memoria"; ++localRevision; return false;
}
++weatherGeneration; weatherFailed = false;
netName = chosenName; netPass = typedPassword; weatherConfigured = true;
typedPassword = ""; chosenName = "";
haveLocation = false; locatePending = true; locationAttempted = false; weatherCity[0] = 0; haveWeather = haveOffset = false;
weatherUpdated = weatherLastAttempt = 0;
WiFi.disconnect(); WiFi.begin(netName.c_str(), netPass.c_str());
lastConnectionAttempt = millis();
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
connectionStarted = millis(); localView = 0; invalidateScreen(); return true;
}
// Convert UTF-8 place names to the display's basic font; bound the text to one line.
static void cityForDisplay(const char* input, char* output, size_t capacity) {
size_t n = 0;
if (!input || !capacity) { if (capacity) output[0] = 0; return; }
while (*input && n + 1 < capacity) {
unsigned char c = (unsigned char)*input++;
if (c >= 32 && c < 127) output[n++] = (char)c;
else if (c == 0xC3 && *input) {
unsigned char b = (unsigned char)*input++;
char mapped = '?';
if (b >= 0x80 && b <= 0x85) mapped = 'A';
else if (b == 0x87) mapped = 'C';
else if (b >= 0x88 && b <= 0x8B) mapped = 'E';
else if (b >= 0x8C && b <= 0x8F) mapped = 'I';
else if (b == 0x91) mapped = 'N';
else if (b >= 0x92 && b <= 0x96) mapped = 'O';
else if (b >= 0x99 && b <= 0x9C) mapped = 'U';
else if (b >= 0xA0 && b <= 0xA5) mapped = 'a';
else if (b == 0xA7) mapped = 'c';
else if (b >= 0xA8 && b <= 0xAB) mapped = 'e';
else if (b >= 0xAC && b <= 0xAF) mapped = 'i';
else if (b == 0xB1) mapped = 'n';
else if (b >= 0xB2 && b <= 0xB6) mapped = 'o';
else if (b >= 0xB9 && b <= 0xBC) mapped = 'u';
output[n++] = mapped;
} else if (c >= 0x80) {
while (((unsigned char)*input & 0xC0) == 0x80) ++input;
output[n++] = '?';
}
}
if (*input && n) output[n - 1] = '~'; // Name truncated.
output[n] = 0;
}
static void weatherTask(void* arg) {
WeatherRequest req = *static_cast<WeatherRequest*>(arg);
delete static_cast<WeatherRequest*>(arg);
WeatherResult result = {}; result.generation = req.generation;
// Approximate public-IP location, not GPS.
if (req.locate) {
result.locationAttempted = true;
WiFiClientSecure geo; geo.setCACert(GEO_CA); geo.setTimeout(8);
HTTPClient gh; gh.setTimeout(8000); gh.setConnectTimeout(8000);
if (gh.begin(geo, "https://ipwho.is/")) {
int geoStatus = gh.GET();
if (geoStatus == 200 && gh.getSize() <= 8192) {
String body = gh.getString(); JsonDocument d;
if (body.length() <= 8192 && !deserializeJson(d, body) && d["success"] == true &&
d["latitude"].is<float>() && d["longitude"].is<float>()) {
float lat = d["latitude"].as<float>(), lon = d["longitude"].as<float>();
if (isfinite(lat) && isfinite(lon) && fabs(lat) <= 90 && fabs(lon) <= 180) {
req.lat = lat; req.lon = lon; req.haveLocation = true;
result.locationOk = true; result.lat = lat; result.lon = lon;
cityForDisplay(d["city"] | "", result.city, sizeof(result.city));
}
}
}
gh.end();
}
}
if (!req.haveLocation) { xQueueSend(weatherQueue, &result, portMAX_DELAY); vTaskDelete(nullptr); return; }
{ // Destroy TLS/HTTP objects before deleting the FreeRTOS task.
WiFiClientSecure client; client.setCACert(WEATHER_CA); client.setTimeout(8);
HTTPClient http; http.setTimeout(8000); http.setConnectTimeout(8000);
char url[256];
snprintf(url, sizeof(url), "https://api.open-meteo.com/v1/forecast?latitude=%.5f&longitude=%.5f¤t=temperature_2m,weather_code,wind_speed_10m,is_day&timezone=auto&forecast_days=1", req.lat, req.lon);
if (http.begin(client, url)) {
int status = http.GET();
if (status == 200 && http.getSize() <= 10000) {
String body = http.getString();
if (body.length() <= 10000) {
JsonDocument doc;
if (!deserializeJson(doc, body) && doc["current"]["temperature_2m"].is<float>() &&
doc["current"]["weather_code"].is<int>() && doc["utc_offset_seconds"].is<int>()) {
result.temp = doc["current"]["temperature_2m"].as<float>();
result.wind = doc["current"]["wind_speed_10m"] | 0.0f;
result.code = doc["current"]["weather_code"].as<int>();
int day = doc["current"]["is_day"] | -1;
result.isDay = (day == 0 || day == 1) ? day : -1;
result.offset = doc["utc_offset_seconds"].as<int>(); result.ok = true;
}
}
}
http.end();
}
}
xQueueSend(weatherQueue, &result, portMAX_DELAY);
vTaskDelete(nullptr);
}
static void serviceWeather(uint32_t now) {
WeatherResult result;
if (weatherQueue && xQueueReceive(weatherQueue, &result, 0) == pdTRUE) {
weatherWorker = false;
if (result.generation == weatherGeneration) {
weatherFailed = !result.ok;
if (result.locationAttempted) locationAttempted = true;
if (result.locationOk) {
strlcpy(weatherCity, result.city, sizeof(weatherCity));
weatherLat = result.lat; weatherLon = result.lon; haveLocation = true; locatePending = false;
if (!saveWeatherConfig(netName, netPass, weatherLat, weatherLon, true)) Serial.println("Memoria interna: no se guardo la ubicacion");
}
}
if (result.ok && result.generation == weatherGeneration && page == 3 && weatherConfigured) {
weatherTemp = result.temp; weatherWind = result.wind; weatherCode = result.code;
weatherOffset = result.offset; weatherIsDay = result.isDay; haveWeather = haveOffset = true; weatherUpdated = now;
}
}
if (page == 3 && !weatherMode) {
// Release the shared 2.4 GHz radio for association and weather.
if (scanner && !blePaused) {
if (bleOk && !scanFinished) scanner->stop();
scanner->clearResults();
// Keep controller memory reinitializable; deinitialization frees stack heap for TLS.
BLEDevice::deinit(false);
scanner = nullptr;
scanFinished = false;
blePaused = true;
}
esp_wifi_set_promiscuous(false);
WiFi.mode(WIFI_STA);
WiFi.persistent(false); WiFi.setAutoReconnect(true);
weatherMode = true;
if (weatherConfigured) { WiFi.disconnect(); WiFi.begin(netName.c_str(), netPass.c_str()); configTime(0, 0, "pool.ntp.org", "time.nist.gov"); connectionStarted = lastConnectionAttempt = now; }
// Refresh the IP location on each visit; keep saved coordinates as a fallback.
weatherLastAttempt = 0; locatePending = true; locationAttempted = false; weatherCity[0] = 0;
}
if (page != 3 && weatherMode && !weatherWorker) {
cancelLocalScan(); localView = 0; typedPassword = ""; chosenName = "";
WiFi.disconnect(); WiFi.mode(WIFI_STA);
wifi_promiscuous_filter_t filter = {};
filter.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA;
wifiOk = esp_wifi_set_promiscuous_filter(&filter) == ESP_OK &&
esp_wifi_set_promiscuous_rx_cb(wifiPacket) == ESP_OK &&
esp_wifi_set_promiscuous(true) == ESP_OK;
weatherMode = false; haveWeather = haveOffset = false;
if (blePaused) startBleRadar();
return;
}
if (!weatherMode || page != 3) return;
serviceLocalScan();
// Automatic reconnect is bounded, and never interrupts a network scan or a weather request.
if (weatherConfigured && !localView && !localScanning && !weatherWorker &&
WiFi.status() != WL_CONNECTED && now - lastConnectionAttempt >= 30000UL) {
WiFi.disconnect();
WiFi.begin(netName.c_str(), netPass.c_str());
lastConnectionAttempt = now;
}
if (!localView && weatherQueue && weatherConfigured && WiFi.status() == WL_CONNECTED && time(nullptr) > 1700000000 &&
!weatherWorker && (!weatherLastAttempt || now - weatherLastAttempt >= (haveWeather && !weatherFailed && !locatePending ? 900000UL : 60000UL))) {
weatherLastAttempt = now;
WeatherRequest* req = new WeatherRequest{weatherLat, weatherLon, locatePending, haveLocation, weatherGeneration};
if (req && xTaskCreate(weatherTask, "clima", 8192, req, 1, nullptr) == pdPASS) weatherWorker = true;
else delete req;
}
}
static const char* weatherDescription(int code) {
if (code == 0) return "DESPEJADO";
if (code <= 3) return "NUBES";
if (code == 45 || code == 48) return "NIEBLA";
if (code >= 95) return "TORMENTA";
if ((code >= 71 && code <= 77) || code == 85 || code == 86) return "NIEVE";
if (code >= 51) return "LLUVIA";
return "VARIABLE";
}
// ============================================================
// DIBUJO UI
// ============================================================
#define tft canvas
// Approximate geocentric elongation using the leading periodic terms.
// The UTC clock drives this calculation; it is not an observation or moonrise prediction.
static double lunarPhase(time_t epoch) {
const double rad = PI / 180.0;
double t = (((double)epoch / 86400.0 + 2440587.5) - 2451545.0) / 36525.0;
double l = 218.3164477 + 481267.88123421*t - 0.0015786*t*t;
double d = (297.8501921 + 445267.1114034*t - 0.0018819*t*t)*rad;
double m = (357.5291092 + 35999.0502909*t - 0.0001536*t*t)*rad;
double mp = (134.9633964 + 477198.8675055*t + 0.0087414*t*t)*rad;
double f = (93.2720950 + 483202.0175233*t - 0.0036539*t*t)*rad;
double moon = l + 6.288774*sin(mp) + 1.274027*sin(2*d-mp) + 0.658314*sin(2*d)
+ 0.213618*sin(2*mp) - 0.185116*sin(m) - 0.114332*sin(2*f)
+ 0.058793*sin(2*d-2*mp) + 0.057066*sin(2*d-m-mp);
double sunMean = 280.46646 + 36000.76983*t + 0.0003032*t*t;
double sun = sunMean + (1.914602 - 0.004817*t)*sin(m)
+ (0.019993 - 0.000101*t)*sin(2*m) + 0.000289*sin(3*m) - 0.00569;
double angle = fmod(moon - sun, 360.0);
if (angle < 0) angle += 360.0;
return angle / 360.0;
}
static void drawMoon(int mx, int my, int r, double phase, bool south) {
tft.fillCircle(mx, my, r, 0x3186); // dark side remains visible at new moon
float terminator = cosf(2.0f * PI * phase);
for (int y = -r; y <= r; ++y) {
int edge = (int)sqrtf((float)(r*r - y*y));
for (int x = -edge; x <= edge; ++x) {
int xx = south ? -x : x;
if (phase < .5 ? xx > terminator * edge : xx <= -terminator * edge)
tft.drawPixel(mx + x, my + y, ILI9341_WHITE);
}
}
}
// Uncompressed 24-bit BMPs, exactly 72x62; read complete frame before drawing.
static uint32_t weatherSdRetry = 0;
static char weatherSdCachedPath[48] = {};
static uint8_t *weatherSdPixels = nullptr;
static uint32_t le32(const uint8_t *p) { return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; }
static bool drawWeatherSd(int code, int day, int frame) {
if (code < 0 || day < 0 || (weatherSdRetry && (int32_t)(millis() - weatherSdRetry) < 0)) return false;
const char *kind = code >= 95 ? "tormenta" : ((code >= 71 && code <= 77) || code == 85 || code == 86) ? "nieve" :
((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) ? "lluvia" :
(code == 45 || code == 48) ? "niebla" : code >= 1 && code <= 3 ? (day ? "nubes_d" : "nubes_n") : (day ? "sol_d" : "sol_n");
char path[48]; snprintf(path, sizeof(path), "/clima/%s%d.bmp", kind, frame % 4);
if (weatherSdPixels && !strcmp(path, weatherSdCachedPath)) {
for (int y = 0; y < 62; ++y) for (int x = 0; x < 72; ++x) {
const uint8_t *p = weatherSdPixels + ((61-y)*72+x)*3;
canvas.drawPixel(16+x, 148+y, ((p[2]&0xf8)<<8) | ((p[1]&0xfc)<<3) | (p[0]>>3));
}
return true;
}
bool ok = openCard();
File f; uint8_t header[54]; uint8_t *data = nullptr;
if (ok) { f = SD.open(path, FILE_READ); ok = f && f.read(header, sizeof(header)) == sizeof(header); }
uint32_t offset = ok ? le32(header + 10) : 0;
if (ok) ok = header[0] == 'B' && header[1] == 'M' && le32(header+14) >= 40 &&
le32(header+18) == 72 && le32(header+22) == 62 && header[26] == 1 && header[27] == 0 &&
header[28] == 24 && header[29] == 0 && le32(header+30) == 0 && offset >= 54 && offset <= f.size() && f.size()-offset >= 72U*62U*3U;
if (ok) { data = (uint8_t*)malloc(72U*62U*3U); ok = data && f.seek(offset) && f.read(data, 72U*62U*3U) == 72U*62U*3U; }
if (f) f.close(); closeCard();
if (ok) {
for (int y = 0; y < 62; ++y) for (int x = 0; x < 72; ++x) {
const uint8_t *p = data + ((61-y)*72+x)*3;
canvas.drawPixel(16+x, 148+y, ((p[2]&0xf8)<<8) | ((p[1]&0xfc)<<3) | (p[0]>>3));
}
weatherSdRetry = 0;
free(weatherSdPixels); weatherSdPixels = data; data = nullptr;
strlcpy(weatherSdCachedPath, path, sizeof(weatherSdCachedPath));
} else weatherSdRetry = millis() + 10000;
free(data); return ok;
}
// All artwork stays within x=16..87, y=148..209, allowing a small buffered update.
static void drawWeatherIcon(int code, int day, bool clockOk, double lunar, int frame) {
if (drawWeatherSd(code, day, frame / 3)) return;
tft.fillRect(16, 148, 72, 62, 0x0004);
if (code < 0 || day < 0) {
tft.setTextSize(2); tft.setTextColor(0x5D5D); tft.setCursor(46, 162); tft.print("?");
return;
}
bool snow = (code >= 71 && code <= 77) || code == 85 || code == 86;
bool rain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || code >= 95;
bool fog = code == 45 || code == 48;
bool clouds = code == 1 || code == 2 || code == 3 || fog || rain || snow;
bool sky = code <= 2;
if (sky && day) {
int sx = clouds ? 46 : 51, sy = clouds ? 163 : 172;
tft.fillCircle(sx, sy, 9, ILI9341_YELLOW);
for (int i = 0; i < 8; ++i) {
float a = (i * 45.0f + frame * 7.5f) * PI / 180.0f;
tft.drawLine(sx + (int)(12*cosf(a)), sy + (int)(12*sinf(a)),
sx + (int)(16*cosf(a)), sy + (int)(16*sinf(a)), ILI9341_YELLOW);
}
} else if (sky && !day) {
if (clockOk) drawMoon(48, clouds ? 163 : 170, 9, lunar, haveLocation && weatherLat < 0);
else tft.drawCircle(48, 166, 9, 0x5D5D);
for (int i = 0; i < 3; ++i) {
int x = 24 + i*19, y = 152 + (i%2)*7;
if ((frame / 5 + i) % 3) { tft.drawPixel(x, y, ILI9341_WHITE); tft.drawPixel(x+1, y, ILI9341_WHITE); }
}
}
if (clouds) {
int dx = (int)roundf(3.0f * sinf(frame * 2.0f * PI / 48.0f));
int x = 50 + dx, y = sky ? 178 : 171;
uint16_t shade = (rain || snow || code == 3) ? 0x5D5D : ILI9341_LIGHTGREY;
tft.fillCircle(x-12, y, 8, shade);
tft.fillCircle(x-2, y-5, 11, shade);
tft.fillCircle(x+11, y-1, 9, shade);
tft.fillRoundRect(x-20, y-1, 40, 10, 3, shade);
tft.drawFastHLine(x-13, y+6, 26, ILI9341_LIGHTGREY);
if (fog) {
for (int i = 0; i < 3; ++i) tft.drawFastHLine(30 + ((frame/3+i*3)%8), 184+i*6, 32, ILI9341_LIGHTGREY);
} else if (snow) {
for (int i = 0; i < 6; ++i) {
int yflake = 184 + (frame + i*5) % 22;
int xflake = 29 + i*8 + (int)roundf(2*sinf((frame+i*7)*PI/24.0f));
tft.drawFastHLine(xflake-2, yflake, 5, ILI9341_WHITE);
tft.drawFastVLine(xflake, yflake-2, 5, ILI9341_WHITE);
}
} else if (rain) {
for (int i = 0; i < 7; ++i) {
int ydrop = 184 + (frame*3 + i*7) % 20;
int xdrop = 30 + i*7;
tft.drawLine(xdrop, ydrop, xdrop-2, ydrop+4, ILI9341_CYAN);
}
if (code >= 95 && frame % 24 < 4) {
tft.drawLine(53, 180, 49, 190, ILI9341_YELLOW);
tft.drawLine(49, 190, 54, 190, ILI9341_YELLOW);
tft.drawLine(54, 190, 49, 200, ILI9341_YELLOW);
}
}
}
}
static void drawButton(int x, int y, int w, int h, const char* label, uint16_t color) {
tft.fillRoundRect(x, y, w, h, 4, 0x10A4);
tft.drawRoundRect(x, y, w, h, 4, 0x3186);
tft.drawFastHLine(x + 5, y + 1, w - 10, color);
tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE);
int tw = strlen(label) * 6;
tft.setCursor(x + (w - tw) / 2, y + (h - 8) / 2); tft.print(label);
}
static void header() {
tft.fillRect(0, 0, SCR_W, 38, 0x0842);
tft.drawFastHLine(0, 37, SCR_W, 0x032A);
tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE);
const int x[] = {4, 64, 100, 162}, w[] = {56, 32, 58, 74};
const char* labels[] = {"RADAR", "IR", "SUB-G", "CLIMA"};
for (int i = 0; i < 4; ++i) {
uint16_t bg = page == i ? ILI9341_CYAN : 0x10A4;
tft.fillRoundRect(x[i], 5, w[i], 27, 4, bg);
tft.drawRoundRect(x[i], 5, w[i], 27, 4, page == i ? ILI9341_CYAN : 0x3186);
tft.setTextColor(page == i ? ILI9341_BLACK : ILI9341_LIGHTGREY);
tft.setCursor(x[i] + (w[i] - (int)strlen(labels[i]) * 6) / 2, 15);
tft.print(labels[i]);
}
}
static void drawPasswordInfo() {
tft.fillRect(0, 38, SCR_W, 59, 0x0004);
tft.setTextSize(1); tft.setTextColor(localMessage.length() ? ILI9341_RED : ILI9341_CYAN);
tft.setCursor(8, 44);
if (localMessage.length()) tft.print(localMessage);
else tft.printf("CLAVE %u/63 %s", (unsigned)typedPassword.length(), passwordVisible ? "VISIBLE" : "OCULTA");
tft.setTextColor(ILI9341_WHITE); tft.setCursor(8, 56); tft.print(chosenName.substring(0, 32));
tft.setTextColor(ILI9341_LIGHTGREY);
// 32 characters per line, so even a 63-character key can be checked.
for (unsigned i = 0; i < typedPassword.length(); ++i) {
tft.setCursor(8 + (i % 32) * 6, 71 + (i / 32) * 13);
char c = typedPassword[i];
tft.print(passwordVisible ? c : '*');
}
}
static void drawScreen() {
if (calibrating) return;
canvas.waterfallColors = (page == 2 && subMode == 0);
int top[3] = {-1, -1, -1}, countWifi = 0, countBle = 0;
uint32_t now = millis();
for (int i = 0; i < MAX_DEVICES; ++i) {
if (!devices[i].used || now - devices[i].seen > 30000) continue;
if (devices[i].e.ble) ++countBle; else ++countWifi;
for (int j = 0; j < 3; ++j) {
if (top[j] < 0 || devices[i].e.rssi > devices[top[j]].e.rssi) {
for (int k = 2; k > j; --k) top[k] = top[k - 1];
top[j] = i; break;
}
}
}
if (page == 4) {
if (!strcmp(displaySnapshot, "DIAGNOSTICO")) return;
strlcpy(displaySnapshot, "DIAGNOSTICO", sizeof(displaySnapshot));
tft.fillScreen(0x0004); header();
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
tft.setCursor(10, 48); tft.print("BATERIA / MICROSD");
tft.setTextColor(ILI9341_WHITE); tft.setCursor(10, 70);
if (batteryLevel < 0) tft.print("Bateria: sin lectura I2C");
else tft.printf("Bateria: ~%d%% (4 niveles)", batteryLevel);
tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(10, 86);
tft.print("Estimacion, no voltaje medido.");
tft.setCursor(10, 112);
if (sdResult == 0) tft.print("microSD: pulsa PROBAR");
else if (sdResult == 2) tft.print("microSD: no se pudo montar");
else tft.printf("microSD: FAT leida, %llu MiB", (unsigned long long)sdMiB);
tft.setCursor(10, 134); tft.print("Ultima prueba; no guarda datos.");
for (int i = 0; i < 3; ++i) if (sdFiles[i][0]) {
tft.setCursor(10, 154 + 15*i); tft.printf("%.34s", sdFiles[i]);
}
drawButton(10, 222, 220, 34, "PROBAR MICROSD", ILI9341_CYAN);
drawButton(10, 269, 220, 34, "VOLVER AL RADAR", ILI9341_GREEN);
flushRect(0, 0, SCR_W, SCR_H); return;
}
if (page == 3 && localView) {
char snapshot[80];
snprintf(snapshot, sizeof(snapshot), "LOCAL:%u:%lu", localView, (unsigned long)localRevision);
if (!strcmp(snapshot, displaySnapshot)) return;
bool first = strncmp(displaySnapshot, "LOCAL:", 6) != 0;
static uint8_t lastLocalView = 0, lastKeyPage = 255;
static bool lastPasswordVisible = true;
bool onlyText = !first && localView == 2 && lastLocalView == 2 && lastKeyPage == keyPage && lastPasswordVisible == passwordVisible;
lastLocalView = localView; lastKeyPage = keyPage; lastPasswordVisible = passwordVisible;
strlcpy(displaySnapshot, snapshot, sizeof(displaySnapshot));
if (onlyText) {
drawPasswordInfo();
flushRect(0, 38, SCR_W, 59); return;
}
if (first) { tft.fillScreen(0x0004); header(); }
else tft.fillRect(0, 38, SCR_W, SCR_H - 38, 0x0004);
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
if (localView == 1) {
tft.setCursor(8, 45); tft.print("REDES Wi-Fi 2.4 GHz");
tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(8, 59);
if (localMessage.length()) tft.print(localMessage);
else if (localScanPending) tft.print("Esperando consulta de clima...");
else if (localScanning) tft.print("Buscando redes...");
else if (localScanError) tft.print("Error al buscar. Reintenta.");
else if (!localCount) tft.print("Sin redes. Prueba de nuevo.");
else tft.printf("Elige una red %d-%d/%d", localOffset + 1, min(localOffset + 5, localCount), localCount);
for (int row = 0; row < 5 && row + localOffset < localCount; ++row) {
const LocalNetwork& net = localNetworks[row + localOffset]; int y = 72 + row * 34;
tft.fillRoundRect(6, y, 228, 31, 3, 0x10A4);
tft.drawRoundRect(6, y, 228, 31, 3, 0x3186);
tft.setTextColor(ILI9341_WHITE); tft.setCursor(11, y + 5);
tft.print(net.name.substring(0, 32));
tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(11, y + 18);
tft.printf("%ld dBm %s", (long)net.rssi, net.open ? "ABIERTA" : "CON CLAVE");
}
drawButton(5, 251, 54, 31, "<", ILI9341_CYAN);
drawButton(62, 251, 54, 31, ">", ILI9341_CYAN);
drawButton(119, 251, 116, 31, "BUSCAR", ILI9341_CYAN);
drawButton(5, 287, 230, 28, "VOLVER AL CLIMA", ILI9341_CYAN);
} else {
drawPasswordInfo();
for (int row = 0; row < 4; ++row) {
size_t len = strlen(keyRows[keyPage][row]);
for (int col = 0; col < (int)len; ++col) {
char label[2] = {keyRows[keyPage][row][col], 0};
drawButton(2 + col * 24, 101 + row * 35, 22, 32, label, ILI9341_CYAN);
}
}
drawButton(2, 248, 46, 30, keyPage == 0 ? "abc" : keyPage == 1 ? "ABC" : "SIM", ILI9341_CYAN);
drawButton(50, 248, 60, 30, "ESPACIO", ILI9341_CYAN);
drawButton(112, 248, 52, 30, "BORRA", ILI9341_CYAN);
drawButton(166, 248, 72, 30, "CONECTAR", ILI9341_GREEN);
drawButton(2, 284, 114, 30, passwordVisible ? "OCULTAR" : "MOSTRAR", ILI9341_CYAN);
drawButton(120, 284, 118, 30, "CANCELAR", ILI9341_CYAN);
}
if (first) flushRect(0, 0, SCR_W, SCR_H); else flushRect(0, 38, SCR_W, SCR_H - 38);
return;
}
// Clock and animated weather are buffered and flushed as separate small regions.
if (page == 3) {
bool first = strcmp(displaySnapshot, "CLIMA") != 0;
static char previousClock[96] = {}, previousWeather[128] = {};
static int previousAnimation = -1;
static char previousCityLine[40] = {};
if (first) {
tft.fillScreen(0x0004); header();
tft.drawRoundRect(8, 60, 224, 74, 7, 0x032A);
tft.drawRoundRect(8, 140, 224, 92, 7, 0x032A);
drawButton(8, 241, 108, 38, clock12Hour ? "HORA: 12 H" : "HORA: 24 H", ILI9341_CYAN);
drawButton(124, 241, 108, 38, temperatureF ? "TEMP: F" : "TEMP: C", ILI9341_CYAN);
drawButton(8, 284, 108, 30, "BUSCAR RED", ILI9341_CYAN);
drawButton(124, 284, 108, 30, "OLVIDAR RED", ILI9341_CYAN);
strlcpy(displaySnapshot, "CLIMA", sizeof(displaySnapshot)); previousClock[0] = previousWeather[0] = previousCityLine[0] = 0; previousAnimation = -1;
flushRect(0, 0, SCR_W, SCR_H);
}
char cityLine[40];
if (weatherCity[0]) snprintf(cityLine, sizeof(cityLine), "CIUDAD ~ %s", weatherCity);
else if (!weatherConfigured) strlcpy(cityLine, "CIUDAD: CONFIGURA Wi-Fi", sizeof(cityLine));
else if (locationAttempted) strlcpy(cityLine, "CIUDAD: NO DISPONIBLE", sizeof(cityLine));
else strlcpy(cityLine, "CIUDAD: BUSCANDO...", sizeof(cityLine));
if (strcmp(previousCityLine, cityLine)) {
strlcpy(previousCityLine, cityLine, sizeof(previousCityLine));
tft.fillRect(12, 42, 216, 12, 0x0004);
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN); tft.setCursor(12, 45); tft.print(cityLine);
flushRect(12, 42, 216, 12);
}
time_t epoch = time(nullptr); bool clockOk = epoch > 1700000000;
time_t localEpoch = epoch + (haveOffset ? weatherOffset : 0);
struct tm local = {}; if (clockOk) gmtime_r(&localEpoch, &local);
bool offsetStale = haveOffset && now - weatherUpdated > 1800000UL;
// Phase is calculated from UTC, regardless of the local time-zone offset.
double lunar = clockOk ? lunarPhase(epoch) : 0;
const char* moonName = lunar < .0625 || lunar >= .9375 ? "LUNA NUEVA" :
lunar < .1875 ? "CRECIENTE" : lunar < .3125 ? "CUARTO CRECIENTE" :
lunar < .4375 ? "GIBOSA CRECIENTE" : lunar < .5625 ? "LUNA LLENA" :
lunar < .6875 ? "GIBOSA MENGUANTE" : lunar < .8125 ? "CUARTO MENGUANTE" : "MENGUANTE";
int day = haveWeather && !offsetStale ? weatherIsDay : -1;
char clockSnap[96];
snprintf(clockSnap, sizeof(clockSnap), "%d:%d:%d:%d:%d:%d:%d:%d:%d", clockOk ? local.tm_sec : -1, local.tm_min, local.tm_hour,
local.tm_yday, haveOffset, offsetStale, clock12Hour, day, (int)(lunar * 1000));
if (strcmp(previousClock, clockSnap)) {
strlcpy(previousClock, clockSnap, sizeof(previousClock));
tft.fillRect(12, 64, 216, 66, 0x0004);
tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3); tft.setCursor(23, 70);
if (clockOk) {
int hour = clock12Hour ? (local.tm_hour % 12 ? local.tm_hour % 12 : 12) : local.tm_hour;
tft.printf("%02d:%02d:%02d", hour, local.tm_min, local.tm_sec);
if (clock12Hour) { tft.setTextSize(1); tft.setCursor(177, 89); tft.print(local.tm_hour < 12 ? "AM" : "PM"); }
} else tft.print("--:--:--");
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(18, 102);
if (clockOk) tft.printf("%02d/%02d/%04d %s %s", local.tm_mday, local.tm_mon + 1, local.tm_year + 1900,
haveOffset ? (offsetStale ? "LOCAL*" : "LOCAL") : "UTC", day < 0 ? "--" : day ? "DIA" : "NOCHE");
else tft.print("Esperando hora por Internet");
tft.setCursor(18, 119); tft.setTextColor(ILI9341_CYAN);
if (clockOk) {
int lit = (int)round((1.0 - cos(2.0 * PI * lunar)) * 50.0);
tft.printf("%s %d%%", moonName, lit);
drawMoon(208, 117, 10, lunar, haveLocation && weatherLat < 0);
} else tft.print("LUNA: SIN HORA");
flushRect(12, 64, 216, 66);
}
int anim = haveWeather ? (now / 150) % 2640 : 0;
wl_status_t wifiStatus = WiFi.status();
int status = wifiStatus == WL_CONNECTED ? 2 : (weatherConfigured ? 1 : 0);
char weatherSnap[128];
snprintf(weatherSnap, sizeof(weatherSnap), "%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d", status, haveWeather, weatherCode,
(int)(weatherTemp * 10), (int)weatherWind, day, temperatureF, weatherFailed, (int)wifiStatus,
(connectionStarted && now - connectionStarted > 20000UL) + 2 * offsetStale, clockOk);
bool weatherChanged = strcmp(previousWeather, weatherSnap) != 0;
if (weatherChanged || anim != previousAnimation) {
previousAnimation = anim;
strlcpy(previousWeather, weatherSnap, sizeof(previousWeather));
if (weatherChanged) tft.fillRect(12, 144, 216, 84, 0x0004);
drawWeatherIcon(haveWeather ? weatherCode : -1, day, clockOk, lunar, anim);
if (weatherChanged) {
tft.setTextSize(2); tft.setTextColor(ILI9341_WHITE); tft.setCursor(104, 155);
if (haveWeather) tft.printf("%.1f %c", temperatureF ? weatherTemp * 1.8f + 32.0f : weatherTemp, temperatureF ? 'F' : 'C');
else tft.printf("--.- %c", temperatureF ? 'F' : 'C');
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN); tft.setCursor(104, 182);
tft.print(haveWeather ? weatherDescription(weatherCode) : "SIN DATOS");
tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(104, 198);
if (haveWeather) tft.printf("Viento %.0f km/h", weatherWind); else tft.print("Espera conexion");
tft.setCursor(12, 215);
if (status == 0) tft.print("Wi-Fi: configura la red");
else if (status == 1 && connectionStarted && now - connectionStarted > 20000UL && wifiStatus == WL_NO_SSID_AVAIL) tft.print("Wi-Fi: red no encontrada");
else if (status == 1 && connectionStarted && now - connectionStarted > 20000UL && wifiStatus == WL_CONNECT_FAILED) tft.print("Wi-Fi: fallo / reintentando");
else if (status == 1 && connectionStarted && now - connectionStarted > 20000UL) tft.print("Wi-Fi: revisa clave / red");
else if (status == 1) tft.print("Wi-Fi: conectando...");
else if (!clockOk) tft.print("Wi-Fi OK - sincronizando hora");
else if (!haveWeather && weatherFailed) tft.print("Ubicacion/clima: reintento");
else if (!haveWeather) tft.print("Wi-Fi OK - clima pendiente");
else if (offsetStale) tft.print("Wi-Fi OK - clima desactualizado");
else tft.print("Wi-Fi OK - clima: Open-Meteo");
flushRect(12, 144, 216, 84);
} else flushRect(16, 148, 72, 62);
}
return;
}
// ---------- SUB-G: teclado ----------
if (page == 2 && subMode == 1) {
char snapshot[80];
snprintf(snapshot, sizeof(snapshot), "KEY:%s:%d", freqInput, freqInvalid);
if (!strcmp(snapshot, displaySnapshot)) return;
strlcpy(displaySnapshot, snapshot, sizeof(displaySnapshot));
tft.fillScreen(0x0004); header();
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN); tft.setCursor(8, 44);
tft.print("FRECUENCIA MANUAL (MHz)");
tft.setTextSize(2); tft.setTextColor(ILI9341_WHITE);
tft.setCursor(12, 60); tft.printf("%s_", freqInput);
tft.setTextSize(1); tft.setCursor(8, 82);
tft.setTextColor(freqInvalid ? ILI9341_RED : ILI9341_LIGHTGREY);
tft.print(freqInvalid ? "Fuera de rango o formato invalido"
: "300-348 / 387-464 / 779-928");
const char* keys[12] = {"1","2","3","4","5","6","7","8","9",".","0","BORRAR"};
for (int i = 0; i < 12; ++i)
drawButton(8 + (i % 3) * 76, 98 + (i / 3) * 42, 68, 36, keys[i], ILI9341_CYAN);
drawButton(8, 275, 104, 34, "CANCELAR", ILI9341_LIGHTGREY);
drawButton(128, 275, 104, 34, "ACEPTAR", ILI9341_GREEN);
flushRect(0, 0, SCR_W, SCR_H);
return;
}
// ---------- SUB-G: fijo / dirección ----------
if (page == 2 && subMode == 2) {
char snapshot[100];
bool moving = bearingAuto && millis() - bearingAutoStart < BEARING_MOVE_MS;
snprintf(snapshot, sizeof(snapshot), "DIR:%lu:%d:%d:%d:%d:%d",
(unsigned long)subRevision, subOk, subError,
bearingAuto, bearingFrozen, moving);
if (!strcmp(snapshot, displaySnapshot)) return;
const bool first = strncmp(displaySnapshot, "DIR:", 4) != 0;
strlcpy(displaySnapshot, snapshot, sizeof(displaySnapshot));
if (first) {
tft.fillScreen(0x0004); header();
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
tft.setCursor(8, 45); tft.print("FIJO / DIRECCION");
tft.setTextColor(ILI9341_WHITE);
tft.setCursor(139, 45); tft.printf("%.3f MHz", subCenter);
tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(8, 61); tft.print("ANTENA MANUAL");
drawButton(130, 54, 102, 19, bearingFilter58 ? "FILTRO 58" : "FILTRO 102", ILI9341_CYAN);
drawButton(8, 77, 48, 32, "<", ILI9341_CYAN);
drawButton(184, 77, 48, 32, ">", ILI9341_CYAN);
drawButton(128, 137, 104, 31, "BORRAR TODO", ILI9341_YELLOW);
drawButton(8, 282, 224, 30, "VOLVER AL BARRIDO", ILI9341_CYAN);
}
drawButton(8, 137, 104, 31, bearingAuto ? "PARAR AUTO" : "AUTO GUIADO", ILI9341_GREEN);
tft.fillRoundRect(60, 77, 120, 32, 4, 0x0842);
tft.drawRoundRect(60, 77, 120, 32, 4, 0x032A);
tft.setTextColor(ILI9341_WHITE); tft.setTextSize(2);
tft.setCursor(81, 86); tft.printf("%3u deg", bearingSlot * 45);
tft.fillRect(8, 110, 224, 26, 0x0004); tft.setTextSize(1); tft.setCursor(8, 111);
if (!subOk) { tft.setTextColor(ILI9341_RED); tft.printf("CC1101 ERROR %d", subError); }
else if (subBusPaused) { tft.setTextColor(ILI9341_YELLOW); tft.print("BUS OCUPADO POR IR"); }
else if (bearingAuto && moving) { tft.setTextColor(ILI9341_YELLOW); tft.print("GIRA TU ANTENA: 4s PARA APUNTAR"); }
else if (bearingAuto) { tft.setTextColor(ILI9341_GREEN); tft.print("MIDIENDO: MANTEN ANTENA FIJA"); }
else if (bearingFrozen) { tft.setTextColor(ILI9341_GREEN); tft.print("RECORRIDO DETENIDO / RESULTADOS"); }
else { tft.setTextColor(ILI9341_LIGHTGREY); tft.print("MODO MANUAL / GIRA Y ESPERA"); }
tft.setCursor(8, 123); tft.setTextColor(ILI9341_WHITE);
if (subOk && !subBusPaused && !(bearingAuto && moving) &&
bearingCount == 5 && millis() - bearingLastSample <= 350)
tft.printf("PROM %d dBm | PICO 5s %d", bearingAverage, bearingPeak);
else { tft.setTextColor(ILI9341_LIGHTGREY); tft.print("PROM -- dBm | PICO 5s --"); }
tft.fillRoundRect(8, 174, 224, 104, 4, 0x0842);
tft.drawRoundRect(8, 174, 224, 104, 4, 0x3186);
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
tft.setCursor(14, 180); tft.print("LECTURAS RELATIVAS / dBm");
tft.drawFastHLine(14, 191, 212, 0x032A);
int best = -1, worst = 0, validCount = 0;
for (int i = 0; i < 8; ++i) {
if (!bearingValid[i]) continue;
if (best < 0 || bearingValues[i] > bearingValues[best]) best = i;
if (validCount == 0 || bearingValues[i] < worst) worst = bearingValues[i];
++validCount;
}
// Similar levels do not support singling out a direction.
bool inconclusive = validCount >= 2 && bearingValues[best] - worst < 3;
for (int i = 0; i < 8; ++i) {
int x = 14 + (i % 2) * 109, y = 197 + (i / 2) * 15;
tft.setCursor(x, y);
tft.setTextColor(i == best && !inconclusive ? ILI9341_YELLOW
: (i == bearingSlot ? ILI9341_CYAN : ILI9341_LIGHTGREY));
if (bearingValid[i]) tft.printf("%3d deg: %4d", i * 45, bearingValues[i]);
else tft.printf("%3d deg: --", i * 45);
}
tft.drawFastHLine(14, 256, 212, 0x032A);
tft.setCursor(14, 264); tft.setTextColor(ILI9341_YELLOW);
if (validCount < 2) tft.print("MAX REL: FALTAN MEDIDAS");
else if (inconclusive) tft.print("MAX REL: NIVELES SIMILARES");
else tft.printf("MAX REL: %d deg %d dBm", best * 45, bearingValues[best]);
if (first) flushRect(0, 0, SCR_W, SCR_H);
else {
flushRect(60, 77, 120, 32);
flushRect(8, 110, 224, 58);
flushRect(8, 174, 224, 104);
}
return;
}
// ---------- SUB-G: espectro / cascada ----------
if (page == 2) {
static uint32_t drawnWaterfallGeneration = 0;
static int drawnThreshold = 0;
static char drawnAlert[48] = {};
char snapshot[80];
snprintf(snapshot, sizeof(snapshot), "SUB:%lu:%d:%d",
(unsigned long)subRevision, subOk, subError);
if (!strcmp(snapshot, displaySnapshot)) return;
const bool first = strncmp(displaySnapshot, "SUB:", 4) != 0;
strlcpy(displaySnapshot, snapshot, sizeof(displaySnapshot));
if (first) {
tft.fillScreen(0x0004); header();
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
tft.setCursor(10, 44); tft.print("SUB-G / ESPECTRO Y CASCADA");
drawWaterfallKey();
drawButton(8, 68, 40, 30, "<", ILI9341_CYAN);
drawButton(192, 68, 40, 30, ">", ILI9341_CYAN);
tft.setTextSize(2); tft.setTextColor(ILI9341_WHITE);
tft.setCursor(60, 76); tft.printf("%.3f", subCenter);
tft.setTextSize(1); tft.setTextColor(ILI9341_CYAN);
tft.setCursor(39, 101);
tft.printf("RANGO %.1f MHz [TOCA]", subSweepHigh() - subSweepLow());
tft.drawRect(9, 127, 222, 99, 0x3186);
tft.drawFastHLine(10, 160, 220, 0x3186);
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(8, 228); tft.printf("%.2f", subSweepLow());
tft.setCursor(96, 228); tft.printf("%.2f", (subSweepLow() + subSweepHigh()) / 2.0f);
tft.setCursor(190, 228); tft.printf("%.2f", subSweepHigh());
drawButton(128, 237, 104, 30, "IR AL PICO", ILI9341_GREEN);
drawButton(8, 288, 104, 29, "FRECUENCIA", ILI9341_CYAN);
drawButton(128, 288, 104, 29, "FIJO / DIR", ILI9341_GREEN);
}
tft.fillRect(8, 113, 224, 12, 0x0004);
tft.setTextSize(1); tft.setCursor(10, 115);
if (wfReplay) { tft.setTextColor(ILI9341_CYAN); tft.printf("TXT PICO %.3f %d dBm", subPeakFreq, subPeak); }
else if (!subOk) { tft.setTextColor(ILI9341_RED); tft.printf("CC1101 ERROR %d", subError); }
else if (subBusPaused) { tft.setTextColor(ILI9341_YELLOW); tft.print("BUS OCUPADO POR IR"); }
else if (!subHasSweep) { tft.setTextColor(ILI9341_LIGHTGREY); tft.print("Esperando barrido..."); }
else {
tft.setTextColor(subPeak >= subThreshold() ? ILI9341_YELLOW : ILI9341_GREEN);
tft.printf("PICO %.3f %d dBm %lums", subPeakFreq, subPeak, (unsigned long)subSweepMs);
}
const bool graphChanged = first || drawnWaterfallGeneration != waterfallGeneration;
if (graphChanged) {
drawnWaterfallGeneration = waterfallGeneration;
tft.fillRect(10, 128, 220, 32, 0x0004);
for (int y = 135; y < 160; y += 8) tft.drawFastHLine(10, y, 220, 0x0868);
for (int x = 10; x < 230; x += 55) tft.drawFastVLine(x, 128, 32, 0x0868);
int centerX = 10 + (int)((subCenter - subSweepLow()) * 219.0f /
(subSweepHigh() - subSweepLow()));
tft.drawFastVLine(constrain(centerX, 10, 229), 128, 32, 0x5D5D);
if (subHasSweep) {
int phPrevX = 10, phPrevY = -1;
for (int i = 0; i < subBinCount(); ++i) {
if (wfPeakHold[i] <= -100) continue;
int x = 10 + i * (WATERFALL_WIDTH - 1) / (subBinCount() - 1);
int y = 159 - map(constrain((int)wfPeakHold[i], -110, -35), -110, -35, 0, 30);
if (i & 1) {
if (phPrevY >= 0) tft.drawLine(phPrevX, phPrevY, x, y, ILI9341_YELLOW);
phPrevX = x; phPrevY = y;
}
}
int previousX = 10, previousY = -1;
for (int i = 0; i < subBinCount(); ++i) {
int x = 10 + i * (WATERFALL_WIDTH - 1) / (subBinCount() - 1);
int y = 159 - map(constrain((int)subBins[i], -110, -35), -110, -35, 0, 30);
if (previousY >= 0) tft.drawLine(previousX, previousY, x, y, ILI9341_CYAN);
if (subBins[i] - subFloor >= WATERFALL_RED_ABOVE_FLOOR)
tft.fillCircle(x, y, 2, ILI9341_RED);
previousX = x; previousY = y;
}
}
drawWaterfall();
}
const bool thresholdChanged = first || drawnThreshold != subThreshold();
if (thresholdChanged) {
drawnThreshold = subThreshold();
drawWaterfallKey();
char thresholdLabel[20];
snprintf(thresholdLabel, sizeof(thresholdLabel), "UMBRAL %d", subThreshold());
drawButton(8, 237, 104, 30, thresholdLabel, ILI9341_YELLOW);
}
char alert[48];
if (!strcmp(wfSdMessage, "SD: GUARDAR CARGAR") && subHistoryCount)
snprintf(alert, sizeof(alert), "ULT %.3f MHz %d dBm", subHistory[0].freq, subHistory[0].rssi);
else snprintf(alert, sizeof(alert), "%s", wfSdMessage);
const bool alertChanged = first || strcmp(alert, drawnAlert);
if (alertChanged) {
strlcpy(drawnAlert, alert, sizeof(drawnAlert));
tft.fillRect(8, 269, 224, 17, 0x0004);
drawButton(8, 269, 104, 17, "SD GUARDAR", ILI9341_CYAN);
drawButton(128, 269, 104, 17, wfReplay ? "EN VIVO" : "SD CARGAR", ILI9341_CYAN);
tft.fillRect(8, 42, 224, 9, 0x0004);
tft.setTextSize(1); tft.setCursor(8, 43); tft.setTextColor(ILI9341_CYAN);
if (!strcmp(wfSdMessage, "SD: GUARDAR CARGAR")) {
if (subHistoryCount) tft.printf("ULT %.3f MHz %d dBm", subHistory[0].freq, subHistory[0].rssi);
else tft.print("SUB-G / ESPECTRO Y CASCADA");
} else tft.print(wfSdMessage);
}
if (first) flushRect(0, 0, SCR_W, SCR_H);
else {
flushRect(8, 113, 224, 12);
if (graphChanged) flushRect(10, 128, 220, 97);
if (thresholdChanged) { flushRect(8, 50, 224, 15); flushRect(8, 237, 104, 30); }
if (alertChanged) { flushRect(8, 269, 224, 17); flushRect(8, 42, 224, 9); }
if (graphChanged) drawWaterfallPeakMarks();
}
return;
}
// ---------- IR ----------
if (page == 1) {
char snapshot[120];
snprintf(snapshot, sizeof(snapshot), "IR:%u:%u:%d:%s", profile, irKeyPage, irLearning, irLine);
if (!strcmp(snapshot, displaySnapshot)) return;
strlcpy(displaySnapshot, snapshot, sizeof(displaySnapshot));
tft.fillScreen(ILI9341_BLACK); header(); tft.setTextSize(1);
tft.setCursor(10, 48); tft.setTextColor(ILI9341_CYAN); tft.print("CONTROL IR / DISPOSITIVOS");
drawButton(8, 65, 32, 30, "<", ILI9341_CYAN);
drawButton(200, 65, 32, 30, ">", ILI9341_CYAN);
tft.setCursor(45, 76); tft.setTextColor(ILI9341_WHITE); tft.print(profiles[profile]);
if (profile == IR_LAST) {
drawButton(16, 112, 208, 48, "REENVIAR ULTIMO", ILI9341_ORANGE);
tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(12, 181); tft.print("Apunta tu mando al receptor.");
tft.setCursor(12, 198); tft.print("Ultimo: se pierde al reiniciar.");
} else {
for (int i = 0; i < 6; ++i) {
int key = i + (profile >= IR_CUSTOM_FIRST ? 6 * irKeyPage : 0);
uint16_t color = ILI9341_CYAN;
if (profile >= IR_CUSTOM_FIRST && irKeys[profile - IR_CUSTOM_FIRST][key].version == 1) color = ILI9341_GREEN;
if (irLearning == key) color = ILI9341_YELLOW;
drawButton(8 + (i % 2) * 116, 103 + (i / 2) * 40, 108, 34, actions[key], color);
}
if (profile >= IR_CUSTOM_FIRST) {
drawButton(8, 227, 108, 29, irKeyPage ? "TECLAS 2/2" : "TECLAS 1/2", ILI9341_CYAN);
drawButton(124, 227, 108, 29, irLearning == -1 ? "APRENDER" : "CANCELAR", ILI9341_YELLOW);
} else {
tft.setCursor(12, 237); tft.setTextColor(ILI9341_LIGHTGREY); tft.print("Ejemplo: varia segun modelo");
}
}
tft.setCursor(8, 265); tft.setTextColor(ILI9341_YELLOW); tft.printf("%.37s", irLine);
tft.setCursor(8, 284); tft.setTextColor(ILI9341_LIGHTGREY);
if (irLearning == -2) tft.print("Elige la tecla a aprender.");
else if (irLearning >= 0) tft.print("Pulsa tu mando hacia receptor.");
else if (profile >= IR_CUSTOM_FIRST && profile < IR_LAST) tft.print("Verde: guardado en memoria.");
else tft.print("Apunta el emisor al equipo.");
tft.setCursor(8, 301); tft.print("IR requiere linea de vision.");
flushRect(0, 0, SCR_W, SCR_H);
return;
}
// ---------- RADAR ----------
static char mapPrevious[180] = {}, statusPrevious[80] = {}, rowsPrevious[3][120] = {};
if (strcmp(displaySnapshot, "RADAR")) {
tft.fillScreen(0x0004); header();
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(10, 186); tft.print("SENALES / 30s");
drawButton(135, 180, 100, 19, "BATERIA/SD", ILI9341_CYAN);
tft.setTextColor(0x5D5D);
tft.setCursor(10, 307); tft.printf("%.36s", rssiCalMessage);
mapPrevious[0] = statusPrevious[0] = 0;
for (int j = 0; j < 3; ++j) rowsPrevious[j][0] = 0;
strlcpy(displaySnapshot, "RADAR", sizeof(displaySnapshot));
flushRect(0, 0, SCR_W, SCR_H);
}
char status[80];
snprintf(status, sizeof(status), "%d:%d:%d:%d:%d", wifiOk, bleOk, countWifi, countBle, weatherMode);
if (strcmp(status, statusPrevious)) {
strlcpy(statusPrevious, status, sizeof(statusPrevious));
tft.fillRect(138, 43, 98, 128, 0x0004);
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(144, 49); tft.print("ULTIMOS 30s");
tft.setTextColor(wifiOk && !weatherMode ? ILI9341_YELLOW : ILI9341_RED);
tft.setCursor(144, 70); tft.print("WIFI");
tft.setTextSize(2); tft.setCursor(144, 82);
if (weatherMode) tft.print("PAUSA"); else if (wifiOk) tft.printf("%02d", countWifi); else tft.print("ERR");
tft.setTextSize(1); tft.setTextColor(bleOk ? ILI9341_CYAN : ILI9341_RED);
tft.setCursor(144, 112); tft.print("BLUETOOTH");
tft.setTextSize(2); tft.setCursor(144, 124);
if (bleOk) tft.printf("%02d", countBle); else tft.print("ERR");
flushRect(138, 43, 98, 128);
}
char map[180] = "map"; size_t used = strlen(map);
for (int j = 0; j < 3; ++j) if (top[j] >= 0 && used < sizeof(map)) {
const Event& e = devices[top[j]].e;
int n = snprintf(map + used, sizeof(map) - used, "|%02X%02X%02X%02X%02X%02X:%d:%d",
e.mac[0], e.mac[1], e.mac[2], e.mac[3], e.mac[4], e.mac[5],
e.ble, e.rssi);
if (n > 0) used += (size_t)n;
}
if (strcmp(map, mapPrevious)) {
strlcpy(mapPrevious, map, sizeof(mapPrevious));
tft.fillRect(4, 40, 132, 140, 0x0004);
const int cx = 69, cy = 106;
tft.fillCircle(cx, cy, 56, 0x0028);
tft.drawCircle(cx, cy, 56, 0x05D2);
tft.drawCircle(cx, cy, 37, 0x032A);
tft.drawCircle(cx, cy, 18, 0x032A);
tft.drawFastHLine(cx - 55, cy, 111, 0x032A);
tft.drawFastVLine(cx, cy - 55, 111, 0x032A);
tft.fillCircle(cx, cy, 2, ILI9341_WHITE);
for (int j = 0; j < 3; ++j) if (top[j] >= 0) {
const Event& e = devices[top[j]].e;
uint32_t hash = 0; for (int k = 0; k < 6; ++k) hash = hash * 33 + e.mac[k];
float angle = (hash % 360) * PI / 180.0f;
float r = constrain(11.0f + (-45.0f - e.rssi) * 0.65f, 11.0f, 50.0f);
int x = cx + (int)(r * cosf(angle)), y = cy + (int)(r * sinf(angle));
uint16_t color = e.ble ? ILI9341_CYAN : ILI9341_YELLOW;
tft.fillCircle(x, y, 6, color);
tft.setTextSize(1); tft.setTextColor(ILI9341_BLACK);
tft.setCursor(x - 3, y - 3); tft.print(j + 1);
}
tft.setTextSize(1); tft.setTextColor(ILI9341_LIGHTGREY);
tft.setCursor(19, 169); tft.print("ANGULO FICTICIO");
flushRect(4, 40, 132, 140);
}
for (int j = 0; j < 3; ++j) {
char row[120];
if (top[j] < 0) snprintf(row, sizeof(row), "empty");
else {
const Entry& d = devices[top[j]]; const Event& e = d.e;
snprintf(row, sizeof(row), "%02X%02X%02X%02X%02X%02X:%d:%d:%s:%d",
e.mac[0], e.mac[1], e.mac[2], e.mac[3], e.mac[4], e.mac[5],
e.ble, e.rssi, e.name, e.ap);
}
if (!strcmp(row, rowsPrevious[j])) continue;
strlcpy(rowsPrevious[j], row, sizeof(rowsPrevious[j]));
int y = 201 + j * 34;
tft.fillRoundRect(6, y, 228, 31, 4, 0x0868);
tft.setTextSize(1);
radarShownValid[j] = top[j] >= 0;
if (top[j] >= 0) radarShown[j] = devices[top[j]].e;
if (top[j] < 0) {
tft.setTextColor(0x5D5D); tft.setCursor(16, y + 12); tft.print("Esperando senal...");
flushRect(6, y, 228, 31); continue;
}
const Event& e = devices[top[j]].e;
float meters = estimateMeters(e);
uint16_t color = e.ble ? ILI9341_CYAN : ILI9341_YELLOW;
tft.fillCircle(16, y + 9, 6, color);
tft.setTextColor(ILI9341_BLACK); tft.setCursor(13, y + 6); tft.print(j + 1);
tft.setTextColor(ILI9341_WHITE); tft.setCursor(27, y + 5);
tft.printf("%.20s", e.name[0] ? e.name : "Sin nombre");
tft.setTextColor(color); tft.setCursor(198, y + 5); tft.print(e.ble ? "BLE" : "WIFI");
const char* kind = guess(e);
if (!strcmp(kind, "posible dron")) kind = "Dron?";
else if (!strcmp(kind, "posible camara")) kind = "Camara?";
else if (!strcmp(kind, "posible movil")) kind = "Movil?";
else if (e.ap) kind = "Red WiFi";
else kind = "Desconocido";
tft.setTextColor(ILI9341_LIGHTGREY); tft.setCursor(14, y + 19); tft.print(kind);
tft.setCursor(97, y + 19); tft.printf("%d dBm", e.rssi);
tft.setCursor(170, y + 19);
if (meters < 0) tft.print("sin ref"); else if (meters >= 100.0f) tft.print("~>99m"); else tft.printf("~%.1fm", meters);
flushRect(6, y, 228, 31);
}
}
#undef tft
// ============================================================
// TOUCH
// ============================================================
static void handleTouch() {
bool down = touch.touched();
if (!down) { touchHeld = false; return; }
if (touchHeld) return;
TS_Point p = touch.getPoint();
if (p.z < 500) return;
touchHeld = true;
if (calibrating) {
calX[calStep] = p.x; calY[calStep] = p.y;
if (++calStep == 4) saveCalibration(); else showCalibration();
return;
}
int x, y;
if (!touchPosition(p, x, y)) return;
if (y < 38) {
uint8_t next = x < 64 ? 0 : (x < 100 ? 1 : (x < 162 ? 2 : 3));
if (next != page) {
if (next == 2) {
subWaiting = false; subBin = 0; subHasSweep = false;
bearingTuned = false; clearWaterfall(); resetBearingSamples(); ++subRevision;
}
if (page == 2) {
wfReplay = false; strlcpy(wfSdMessage, "SD: GUARDAR CARGAR", sizeof(wfSdMessage));
bearingTuned = false; bearingAuto = false; resetBearingSamples();
}
}
if (next != page && rssiCalActive) { rssiCalActive = false; strlcpy(rssiCalMessage, "Calibracion cancelada", sizeof(rssiCalMessage)); }
if (page == 3 && next != 3) { cancelLocalScan(); localView = 0; typedPassword = ""; chosenName = ""; }
if (page == 1 && next != 1) irLearning = -1;
if (next == 3 && next != page) { weatherSdRetry = 0; free(weatherSdPixels); weatherSdPixels = nullptr; weatherSdCachedPath[0] = 0; }
page = next; invalidateScreen(); return;
}
if (page == 3) {
if (!localView && y >= 241 && y < 279) {
if (x < 120) clock12Hour = !clock12Hour; else temperatureF = !temperatureF;
if (prefs.begin("weather-ui", false)) {
prefs.putBool("hour12", clock12Hour); prefs.putBool("tempF", temperatureF); prefs.end();
} else Serial.println("No se pudieron guardar los formatos");
invalidateScreen();
} else if (!localView && y >= 284 && y < 314 && x < 120) {
localView = 1; typedPassword = ""; chosenName = ""; beginLocalScan(); invalidateScreen();
} else if (!localView && y >= 284 && y < 314) {
if (eraseWeatherConfig()) {
++weatherGeneration; weatherConfigured = false; weatherFailed = false;
netName = ""; netPass = ""; haveLocation = haveWeather = haveOffset = false;
weatherCity[0] = 0; locationAttempted = false; locatePending = true;
connectionStarted = 0; WiFi.disconnect();
} else Serial.println("No se pudo borrar la red de memoria interna");
invalidateScreen();
} else if (localView == 1) {
if (y >= 72 && y < 242 && !localScanning && !localScanPending) {
int idx = localOffset + (y - 72) / 34;
if (idx < localCount) {
chosenName = localNetworks[idx].name; typedPassword = ""; localMessage = "";
if (localNetworks[idx].open) connectLocalNetwork();
else { localView = 2; keyPage = 0; passwordVisible = true; ++localRevision; }
}
} else if (y >= 251 && y < 282) {
if (x < 60) localOffset = max(0, localOffset - 5);
else if (x < 117) { if (localOffset + 5 < localCount) localOffset += 5; }
else beginLocalScan();
++localRevision;
} else if (y >= 287) { cancelLocalScan(); localView = 0; invalidateScreen(); }
} else if (localView == 2) {
if (y >= 101 && y < 241) {
int row = (y - 101) / 35, col = x / 24;
if (row < 4 && col < 10 && (y - 101) % 35 < 32 && x % 24 >= 2 &&
col < (int)strlen(keyRows[keyPage][row]) && typedPassword.length() < 63) {
typedPassword += keyRows[keyPage][row][col]; localMessage = ""; ++localRevision;
}
} else if (y >= 248 && y < 278) {
if (x < 49) keyPage = (keyPage + 1) % 3;
else if (x < 111 && typedPassword.length() < 63) { typedPassword += ' '; localMessage = ""; }
else if (x < 165 && typedPassword.length()) { typedPassword.remove(typedPassword.length() - 1); localMessage = ""; }
else if (x >= 165) {
if (!typedPassword.length()) localMessage = "Escribe la clave de la red";
else connectLocalNetwork();
}
++localRevision;
} else if (y >= 284 && y < 314) {
if (x < 118) passwordVisible = !passwordVisible;
else { typedPassword = ""; chosenName = ""; localView = 1; }
++localRevision;
}
}
return;
}
if (page == 4) {
if (y >= 222 && y < 256) { testSdCard(); }
else if (y >= 269 && y < 303) { page = 0; invalidateScreen(); }
return;
}
if (page == 0 && x >= 135 && y >= 180 && y < 200) {
page = 4; readBatteryLevel(); invalidateScreen(); return;
}
if (page == 0 && y >= 201 && y < 303) {
int row = (y - 201) / 34;
if (row < 3 && radarShownValid[row]) {
rssiCalTarget = radarShown[row]; rssiCalStart = millis();
rssiCalSum = rssiCalCount = 0; rssiCalActive = true;
snprintf(rssiCalMessage, sizeof(rssiCalMessage), "1m: %02X%02X midiendo 5s", rssiCalTarget.mac[4], rssiCalTarget.mac[5]);
invalidateScreen();
}
return;
}
if (page == 2) {
if (subMode == 0) {
if (y >= 269 && y < 287) {
if (x < 120 && !wfReplay) saveWaterfallSd();
else if (x >= 120 && wfReplay) { wfReplay = false; resetSubSweep(); strlcpy(wfSdMessage, "SD: GUARDAR CARGAR", sizeof(wfSdMessage)); }
else if (x >= 120) loadWaterfallSd();
++subRevision; invalidateScreen(); return;
}
if (wfReplay) return;
if (y >= 60 && y < 68 && x >= 186 && x < 236) {
wfMode = (WaterfallMode)((wfMode + 1) % 3);
prefs.begin("sentinela", false);
prefs.putUChar("wfMode", (uint8_t)wfMode);
prefs.end();
invalidateScreen();
return;
}
if (y >= 42 && y < 56 && x >= 140 && x < 236) {
wfPaused = !wfPaused;
invalidateScreen();
return;
}
}
if (subMode == 1) {
if (y >= 98 && y < 266) {
int col = (x - 8) / 76, row = (y - 98) / 42;
if (x < 8 || col > 2 || (x - 8) % 76 >= 68 || (y - 98) % 42 >= 36) return;
int key = row * 3 + col, len = strlen(freqInput);
if (key == 11) { if (len) freqInput[len - 1] = 0; }
else {
char c = key == 9 ? '.' : (key == 10 ? '0' : (char)('1' + key));
char* dot = strchr(freqInput, '.');
if (len < 7 && !(c == '.' && (dot || !len)) &&
!(dot && c != '.' && strlen(dot + 1) >= 3)) {
freqInput[len] = c; freqInput[len + 1] = 0;
}
}
freqInvalid = false; invalidateScreen(); return;
}
if (y >= 275 && y < 309) {
if (x >= 8 && x < 112) {
subMode = 0; subWaiting = false; subBin = 0; invalidateScreen();
} else if (x >= 128 && x < 232) {
char* end = nullptr;
float f = strtof(freqInput, &end), lo, hi;
if (!freqInput[0] || *end || freqInput[strlen(freqInput) - 1] == '.' ||
!subRange(f, lo, hi)) freqInvalid = true;
else { subCenter = f; subMode = 0; resetSubSweep(); }
invalidateScreen();
}
}
return;
}
if (subMode == 2) {
if (y >= 54 && y < 73 && x >= 130 && x < 232) {
bearingFilter58 = !bearingFilter58;
bearingAuto = false; bearingFrozen = false; bearingTuned = false;
clearBearings(); ++subRevision; invalidateScreen();
} else if (y >= 77 && y < 109 && (x < 56 || x >= 184)) {
bearingAuto = false; bearingFrozen = false;
bearingSlot = (bearingSlot + (x < 56 ? 7 : 1)) % 8;
resetBearingSamples(); ++subRevision;
} else if (y >= 137 && y < 168) {
if (x >= 8 && x < 112) {
if (bearingAuto) { bearingAuto = false; bearingFrozen = true; }
else { clearBearings(); bearingSlot = 0; bearingFrozen = false;
bearingAuto = true; bearingAutoStart = millis(); }
++subRevision;
} else if (x >= 128 && x < 232) {
bearingAuto = false; bearingFrozen = false; clearBearings(); ++subRevision;
}
} else if (y >= 282 && y < 312) {
bearingAuto = false; subMode = 0; subWaiting = false; subBin = 0;
subHasSweep = false; bearingTuned = false; clearWaterfall();
++subRevision; invalidateScreen();
}
return;
}
if (y >= 68 && y < 98 && (x < 48 || x >= 192)) {
subIndex = (subIndex + (x < 48 ? 3 : 1)) % 4;
subCenter = subFreq[subIndex]; resetSubSweep(); invalidateScreen();
} else if (y >= 98 && y < 113) {
subWide = !subWide; resetSubSweep(); invalidateScreen();
} else if (y >= 237 && y < 267) {
if (x >= 8 && x < 112) {
subThresholdIndex = (subThresholdIndex + 1) %
(sizeof(subThresholds) / sizeof(subThresholds[0]));
clearWaterfall(); subHistoryCount = 0; ++subRevision;
} else if (x >= 128 && x < 232 && subOk && !subBusPaused && subHasSweep) {
subCenter = subPeakFreq; resetSubSweep(); subMode = 2; invalidateScreen();
}
} else if (y >= 288 && y < 317) {
if (x >= 8 && x < 112) {
subMode = 1; subHasSweep = false; clearWaterfall();
freqInput[0] = 0; freqInvalid = false; invalidateScreen();
} else if (x >= 128 && x < 232) {
subMode = 2; bearingTuned = false; resetBearingSamples(); invalidateScreen();
}
}
return;
}
if (page != 1) return;
if (y >= 65 && y < 95) {
if (x < 40) profile = (profile + IR_LAST) % (IR_LAST + 1);
else if (x >= 200) profile = (profile + 1) % (IR_LAST + 1);
else return;
irLearning = -1; irKeyPage = 0;
strlcpy(irLine, "Elige un control", sizeof(irLine));
invalidateScreen(); return;
}
if (profile == IR_LAST && x >= 16 && x < 224 && y >= 112 && y < 160) {
sendAction(0); return;
}
if (profile < IR_LAST && y >= 103 && y < 217) {
int row = (y - 103) / 40, col = x >= 124 ? 1 : 0;
if (row < 3 && (y - 103) % 40 < 34 &&
((col == 0 && x >= 8 && x < 116) || (col == 1 && x >= 124 && x < 232))) {
int key = row * 2 + col + (profile >= IR_CUSTOM_FIRST ? 6 * irKeyPage : 0);
if (profile >= IR_CUSTOM_FIRST && irLearning != -1) {
irLearning = key; irLearnStart = millis();
snprintf(irLine, sizeof(irLine), "Esperando: %s (20s)", actions[key]); invalidateScreen();
} else sendAction(key);
}
return;
}
if (profile >= IR_CUSTOM_FIRST && profile < IR_LAST && y >= 227 && y < 256) {
if (x >= 8 && x < 116) { irKeyPage ^= 1; }
else if (x >= 124 && x < 232) {
irLearning = irLearning == -1 ? -2 : -1;
strlcpy(irLine, irLearning == -2 ? "Selecciona una tecla" : "Aprendizaje cancelado", sizeof(irLine));
} else return;
invalidateScreen();
}
}
// ============================================================
// SETUP / LOOP
// ============================================================
void setup() {
Serial.begin(115200);
delay(500);
pinMode(18, OUTPUT); digitalWrite(18, HIGH);
SPI.begin(36, 37, 35, 17);
tft.begin(27000000);
tft.setRotation(2);
touch.begin(); touch.setRotation(2);
prefs.begin("sentinela", true);
calibrationValid = prefs.getBytesLength("cx") == sizeof(calX) &&
prefs.getBytesLength("cy") == sizeof(calY);
if (calibrationValid) {
prefs.getBytes("cx", calX, sizeof(calX));
prefs.getBytes("cy", calY, sizeof(calY));
calibrationValid = labs(determinant()) > 100000L;
}
{
uint8_t savedMode = prefs.getUChar("wfMode", (uint8_t)WF_MODE_RELATIVE);
if (savedMode <= (uint8_t)WF_MODE_DELTA) wfMode = (WaterfallMode)savedMode;
}
if (prefs.getBytesLength("refWifi") == sizeof(RssiReference)) prefs.getBytes("refWifi", &references[0], sizeof(RssiReference));
if (prefs.getBytesLength("refBle") == sizeof(RssiReference)) prefs.getBytes("refBle", &references[1], sizeof(RssiReference));
prefs.end();
weatherQueue = xQueueCreate(1, sizeof(WeatherResult));
if (!canvas.allocate()) {
tft.fillScreen(ILI9341_BLACK);
tft.setTextColor(ILI9341_RED); tft.setCursor(5, 30);
tft.print("Falta memoria de pantalla");
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
Serial.println("ERROR: memoria insuficiente para pantalla");
return;
}
tft.fillScreen(ILI9341_BLACK);
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
tft.setTextColor(ILI9341_GREEN); tft.setTextSize(2);
tft.setCursor(4, 5); tft.print("SENTINELA");
pinMode(21, INPUT_PULLUP);
irReceiver.enableIRIn(true);
irTransmitter.begin();
loadIrKeys();
for (int pin : {4, 48, 10}) { pinMode(pin, OUTPUT); digitalWrite(pin, HIGH); }
pinMode(15, OUTPUT); digitalWrite(15, LOW);
pinMode(47, OUTPUT); digitalWrite(47, LOW);
pinMode(5, OUTPUT); digitalWrite(5, HIGH);
radioSPI.begin(12, 13, 11, 5);
Wire.begin(8, 9);
Wire.setTimeOut(50);
weatherConfigured = loadWeatherConfig();
// A saved network opens CLIMA on boot; channel-hopping radar and association cannot share the radio.
if (weatherConfigured) page = 3;
if (prefs.begin("weather-ui", true)) {
clock12Hour = prefs.getBool("hour12", false);
temperatureF = prefs.getBool("tempF", false);
prefs.end();
}
ConfigFSK_t radioConfig;
radioConfig.frequency = subFreq[subIndex];
radioConfig.receiverBandwidth = 270.0;
subError = subRadio.begin(radioConfig);
if (!subError) subError = subRadio.setOOK(true);
if (!subError) subError = subRadio.receiveDirectAsync();
subOk = (subError == RADIOLIB_ERR_NONE);
resetSubSweep();
Serial.printf("CC1101: %s (%d), barrido pasivo centrado en %.2f MHz\n",
subOk ? "activo" : "error", subError, subFreq[subIndex]);
Serial.println("IR activo: apunta un mando al receptor. Escribe R para reenviar.");
Serial.println("Sentinela: escucha pasiva, Wi-Fi 2.4 GHz canales 1-11 y anuncios BLE.");
Serial.println("La clase es orientativa. La distancia requiere referencia propia a 1 m.");
events = xQueueCreate(EVENT_QUEUE_LEN, sizeof(Event));
if (!events) {
Serial.println("ERROR: memoria insuficiente para el escaneo");
tft.setCursor(4, 60); tft.print("Error: falta memoria");
return;
}
WiFi.mode(WIFI_STA);
WiFi.disconnect();
WiFi.setSleep(false);
wifi_promiscuous_filter_t filter = {};
filter.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA;
wifiOk = esp_wifi_set_promiscuous_filter(&filter) == ESP_OK &&
esp_wifi_set_promiscuous_rx_cb(wifiPacket) == ESP_OK &&
esp_wifi_set_promiscuous(true) == ESP_OK;
// Do not allocate Bluetooth while starting directly in weather mode.
if (page == 3) { blePaused = true; bleOk = true; }
else startBleRadar();
if (!calibrationValid) startCalibration(); else drawScreen();
}
void loop() {
if (!events || !canvas.getBuffer()) { delay(1000); return; }
static uint32_t lastHop = 0, lastDraw = 0;
static uint8_t channel = 1;
uint32_t now = millis();
if (wifiOk && page != 3 && !weatherMode && now - lastHop >= 250) {
lastHop = now;
esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE);
channel = (channel == 11) ? 1 : channel + 1;
}
if (scanFinished && scanner && !blePaused && page != 3) {
scanFinished = false;
bleOk = scanner->start(5, onScanEnd, false);
}
handleTouch();
if (page == 4 && (batteryFirstRead || now - batteryReadAt >= 5000)) {
batteryFirstRead = false; batteryReadAt = now; readBatteryLevel();
}
serviceWeather(millis());
serviceRssiCalibration(millis());
serviceSubSweep(millis());
if (irLearning >= 0 && millis() - irLearnStart >= 20000) {
irLearning = -1;
strlcpy(irLine, "Tiempo agotado; intenta otra vez", sizeof(irLine));
invalidateScreen();
}
if (irReceiver.decode(&irResult)) {
const bool simple = irResult.decode_type != UNKNOWN && !hasACState(irResult.decode_type) &&
irResult.bits > 0 && irResult.bits <= 64 && !irResult.overflow && !irResult.repeat;
String protocol = typeToString(irResult.decode_type);
if (simple) {
savedType = irResult.decode_type; savedValue = irResult.value; savedBits = irResult.bits;
}
if (page == 1 && irLearning >= 0) {
if (simple && supportedLearnProtocol(irResult.decode_type)) {
const int key = irLearning;
bool ok = saveIrKey(profile - IR_CUSTOM_FIRST, key, irResult);
irLearning = -1;
snprintf(irLine, sizeof(irLine), ok ? "%s guardado: %.10s" : "Error al guardar %s %.10s",
actions[key], protocol.c_str());
} else if (!irResult.repeat) {
strlcpy(irLine, "Senal no admitida; reintenta", sizeof(irLine));
}
} else if (irLearning == -1) {
if (simple) snprintf(irLine, sizeof(irLine), "IR: %.12s %llX", protocol.c_str(), (unsigned long long)irResult.value);
else if (!irResult.repeat) strlcpy(irLine, "IR: senal no compatible", sizeof(irLine));
}
if (simple) Serial.printf("IR recibido: %s, %u bits, 0x%llX\n", protocol.c_str(), irResult.bits, (unsigned long long)irResult.value);
irReceiver.resume();
}
while (Serial.available()) {
char c = Serial.read();
if (c == 'c' || c == 'C') startCalibration();
if (c == 'r' || c == 'R') {
if (!savedBits)
Serial.println("IR: primero recibe un codigo compatible (no aire acondicionado).");
else sendIR(savedType, savedValue, savedBits);
}
}
Event e;
for (int i = 0; i < 100 && xQueueReceive(events, &e, 0) == pdTRUE; ++i) remember(e);
if (now - lastDraw >= (page == 2 ? 50U : page == 3 ? 100U : 200U)) { lastDraw = now; drawScreen(); }
report();
delay((page == 2 && subMode == 0) ? 1 : 5);
}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.




