Community project

ESP32 Development Board

ESP32
Photo of ESP32 Development Board
Generated with AI

tianguisnetor

Published October 29, 2026

This guide walks through building an interactive ESP32 project with a touchscreen display and WiFi connectivity. The project combines an ESP32 microcontroller, a 2.4-inch ILI9341 TFT display with XPT2046 touch input, and secure internet communication to create a connected device.

Following this guide, makers will receive a complete parts list, wiring diagram showing how to connect the display and touch panel to the ESP32's SPI bus, and the full Arduino firmware with WiFi, HTTPS, and graphics rendering capabilities. The included code demonstrates efficient memory management for resource-constrained environments and multilingual support, providing a solid foundation for custom IoT applications.

Wiring diagram

Wiring diagram for ESP32 Development Board
Loading diagram…

Assemble it in 1 step

1. Conecta y usa tu mascota

Usa la pantalla tactil integrada de la ESP32-DIV v2; no necesitas componentes adicionales.

  • Alimenta la placa con un cable USB de datos y pulsa Deploy.
  • Si aparece la calibracion, toca el centro de cada cruz. En Wi-Fi puedes volver a calibrar.
  • Toca Wi-Fi, elige una red de 2,4 GHz y escribe su clave. ABC/# cambia el teclado y Mostrar clave permite revisarla.
  • Cuando conecta guarda la red y reintenta al encender o si pierde la conexion. Si no esta disponible, elige otra desde Wi-Fi.
  • En la pantalla principal toca Chat y Escribir. Prueba ayuda, estado, senal, equipos, buscar, hola o triste.
  • Toca + Util o - No una vez por respuesta para ensenarle tu estilo preferido. La memoria se guarda despues de 20 segundos sin nuevas valoraciones.
  • Toca Equipos para ver las direcciones IP que respondieron y Buscar para repetir la revision. Puede tardar mas de un minuto. Toca una IP para marcarla o desmarcarla como conocida.
  • Desde Equipos toca Avisos para revisar los ultimos ocho eventos guardados. El simbolo ! en la mascota indica que hay avisos guardados.
  • Desde Equipos toca Radio. Con el CC1101 y una antena apropiada comienza un barrido automatico; espera en esa pantalla hasta que termine. Buscar auto repite el barrido; Introducir MHz abre el teclado de frecuencia manual.
  • En Radio usa Scan para buscar automaticamente; Espectro muestra barras RSSI y Cascada es la antigua pestana Sub y muestra el historial de intensidad por frecuencia, con lo mas reciente arriba. Introducir MHz abre un teclado; borra el valor, escribe la frecuencia y toca Aceptar. Elige entre 300-348, 387-464 y 779-928 MHz.
  • En Espectro y Cascada el centro siempre es la frecuencia elegida. Toca Paso para cambiar entre 12,5, 25, 50, 100 y 200 kHz; usa - y + para desplazar el centro. Fuera de banda se dejan casillas vacias. El numero dBm muestra el ruido estimado.
  • Decode escucha en el centro seleccionado o el resultado del escaneo; prueba un mando ASK/OOK propio. Un numero RC indica un patron compatible, no una identificacion segura.
  • Si Radio indica que el receptor no esta disponible, revisa que el shield y el modulo CC1101 esten instalados; toca Reintentar. No se necesita cableado adicional si el modulo ya esta instalado.
  • Toca Reloj para ver hora, fecha y fase lunar. Cambia entre 12 y 24 horas o toca Actualizar para volver a consultar la zona.
  • En Reloj, el clima se consulta automaticamente; toca Actualizar para volver a intentarlo. Open-Meteo ofrece estimaciones meteorologicas.
  • En la pantalla principal, la mascota mueve los brazos, cambia de expresion y muestra mensajes por si sola. Puedes seguir usando la pantalla normalmente.
  • En la pantalla principal toca la cabeza para acariciarla o el cuerpo para jugar; repite para ver diferentes emociones y movimientos.
  • La conversacion es local y limitada a palabras y respuestas programadas; no es un modelo de lenguaje ni aprende conocimientos nuevos.
  • La revision de equipos solo debe usarse en redes propias o autorizadas; no todos los dispositivos responden y una diferencia no demuestra una intrusion. Una IP puede cambiar o ser compartida; marcarla como conocida no verifica la identidad.
  • Se conserva una lista de hasta 64 IP conocidas para la red actual; al cambiar de red o subred se sustituye. El historial guarda ocho avisos, con hora solo si el reloj ya estaba sincronizado.
  • El barrido Sub-GHz es pasivo, recorre 300-348, 387-464 y 779-928 MHz en pasos de 0,2 MHz, con un filtro de 270 kHz, y muestra la frecuencia aproximada de maxima energia, que tambien puede ser ruido. Puede omitir senales breves y no identifica emisores. El modulo, su filtro y antena pueden limitar las frecuencias utiles; no cubre frecuencias fuera de esas bandas.
  • Espectro y Cascada son barridos secuenciales de RSSI, no un analizador SDR ni un registro de audio o paquetes. La grafica recorre 33 posiciones con paso seleccionable y filtro de 58 kHz; la cascada colorea intensidad relativa al ruido estimado, no una escala absoluta. Pasos mayores al filtro pueden saltarse senales y el barrido puede perder emisiones breves. La frecuencia mostrada es la solicitada; la sintonia real tiene resolucion y tolerancia de cristal limitadas. Decode reconoce solo algunos patrones ASK/OOK de RC-Switch y puede dar falsos positivos; no decodifica P25, DMR ni voz analogica, no descifra senales cifradas ni identifica cualquier protocolo. Solo recibe, sin transmitir. Usa estas funciones solo con senales propias o autorizadas.
  • Una red abierta se avisa al elegirla y conectarla desde Wi-Fi; si se guardo antes de esta version, vuelve a seleccionarla para comprobar su tipo. Una red con clave no garantiza seguridad.
  • La clave Wi-Fi y las preferencias aprendidas quedan guardadas en la memoria de la placa. No dejes la placa en manos de desconocidos.
  • El reloj necesita Wi-Fi e internet. La zona se estima a partir de la direccion publica de internet, que puede indicar una ubicacion equivocada; el servicio externo puede fallar. El horario de verano se actualiza por internet aproximadamente cada 15 minutos.
  • El clima y dia o noche provienen de Open-Meteo y de una ubicacion aproximada por IP; pueden ser incorrectos y no son mediciones de un sensor. Si no hay datos recientes se avisa. La fase lunar es aproximada.
  • El programa sustituye el firmware anterior de la placa.

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <Preferences.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include <ping/ping_sock.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h>
#include <esp_heap_caps.h>
#include "root_ca.h"
#include "i18n.h"

// ESP32-DIV v2: TFT reset is wired to EN; touch shares the SPI bus.
Adafruit_ILI9341 tft(17,16,-1);
// Render off-screen, then send only changed horizontal bands.
class PetCanvas : public Adafruit_GFX {
 public:
  uint8_t* buffer;
  // A compact fixed palette halves internal RAM consumption, leaving room
  // for authenticated HTTPS even when external RAM has not been enabled.
  static constexpr uint16_t palette[16]={
   0x0884,0x1148,0x19AA,0x2ACC,0x3DFF,0x9D5A,0xFFFF,0xFFE0,
   0x07E0,0xF81F,0x6E7C,0x1A0A,0x2453,0x10A9,0xAD75,0x7008
  };
  PetCanvas():Adafruit_GFX(240,320),buffer(nullptr) {
   buffer=(uint8_t*)heap_caps_malloc(240*320/2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
   if(!buffer)buffer=(uint8_t*)malloc(240*320/2);
  }
  ~PetCanvas(){free(buffer);}
  uint8_t* getBuffer(){return buffer;}
  static uint8_t encode(uint16_t c){
   int r=(c>>11)&31,g=(c>>5)&63,b=c&31,best=INT_MAX;uint8_t choice=0;
   for(uint8_t i=0;i<16;i++){
    int dr=r-((palette[i]>>11)&31),dg=(g-((palette[i]>>5)&63))/2,db=b-(palette[i]&31);
    int d=dr*dr+dg*dg+db*db;if(d<best){best=d;choice=i;if(!d)break;}
   }return choice;
  }
  static uint16_t decode(uint8_t c){return palette[c&15];}
  uint8_t at(int index) const {uint8_t v=buffer[index/2];return index&1?v&15:v>>4;}
  void drawPixel(int16_t x,int16_t y,uint16_t c) override {
   if(buffer && x>=0 && y>=0 && x<240 && y<320){int i=y*240+x;uint8_t v=encode(c);if(i&1)buffer[i/2]=(buffer[i/2]&0xF0)|v;else buffer[i/2]=(buffer[i/2]&0x0F)|(v<<4);}
  }
  void fillRect(int16_t x,int16_t y,int16_t w,int16_t h,uint16_t c) override {
   if(!buffer || w<=0 || h<=0)return;
   int x0=max(0,(int)x),y0=max(0,(int)y),x1=min(240,(int)x+w),y1=min(320,(int)y+h);
   if(x1<=x0||y1<=y0)return;uint8_t v=encode(c),both=(v<<4)|v;
   for(int yy=y0;yy<y1;yy++){
    int l=yy*240+x0,r=yy*240+x1;
    if(l&1){buffer[l/2]=(buffer[l/2]&0xF0)|v;l++;}
    if(r&1){r--;buffer[r/2]=(buffer[r/2]&0x0F)|(v<<4);}
    if(r>l)memset(buffer+l/2,both,(r-l)/2);
   }
  }
  void fillScreen(uint16_t c) override {if(buffer){uint8_t v=encode(c);memset(buffer,(v<<4)|v,240*320/2);}}
};
constexpr uint16_t PetCanvas::palette[16];
PetCanvas* canvas=nullptr;
Adafruit_GFX* target=&tft;
#define display (*target)
uint32_t bandHash[8]={}; bool firstFrame=true;
void present(){
 if(!canvas || !canvas->getBuffer()) return;
 uint8_t* b=canvas->getBuffer();
 static uint16_t pixels[240*4];
 for(int band=0;band<8;band++){
  uint32_t h=2166136261UL;
  for(int i=0;i<240*40/2;i++){h^=b[band*240*40/2+i];h*=16777619UL;}
  if(firstFrame || h!=bandHash[band]){
   for(int row=0;row<40;row+=4){
    int offset=(band*40+row)*240;
    for(int i=0;i<240*4;i++)pixels[i]=PetCanvas::decode(canvas->at(offset+i));
    tft.drawRGBBitmap(0,band*40+row,pixels,240,4);
   }
   bandHash[band]=h;
  }
 }
 firstFrame=false;
}
XPT2046_Touchscreen touch(18);
Preferences prefs;
String wifiName, wifiPass, message="Configura el Wi-Fi";
bool connected=false, scanning=false, scanDone=false, redraw=true;
unsigned long lastAttempt=0, lastScan=0;
unsigned long lastMoodCheck=0, reactionUntil=0;
// Virtual satiety is not real food. It changes only while the board is on.
int satiety=100;
bool petSleeping=false, hungerScan=false;
// A touch may wake the pet briefly without cancelling its night schedule.
bool wakeOverride=false;
unsigned long wakeStarted=0;
unsigned long lastHungerTick=0, lastHungerAttempt=0;
bool hungerAttempted=false;
int signalQuality=-1;
unsigned previousCount=0;
bool haveBaseline=false;
String petNote="Hola! Soy tu mascota de red.";
enum Mood { CALM, HAPPY, WORRIED, CURIOUS, SLEEPY, LOVE, EXCITED };
Mood mood=CALM;
uint32_t firstIP=0, lastIP=0, nextIP=0, currentIP=0;
uint32_t found[254], pending[254];
unsigned foundCount=0, pendingCount=0, examined=0, total=0;
esp_ping_handle_t pingHandle=nullptr;
portMUX_TYPE pingMux=portMUX_INITIALIZER_UNLOCKED;
volatile bool pingDone=false, pingReply=false;

// A tiny trainable linear neural layer chooses a response style, not facts.
// Preferences uses ESP32 flash-backed NVS (there is no separate EEPROM here).
struct PetBrain { uint32_t magic; int8_t weight[3][5]; uint32_t ratings; };
PetBrain brain{};
bool brainDirty=false, chatInput=false, haveAnswer=false;
unsigned long brainChangedAt=0;
int lastStyle=0, lastFeatures[5]={1,0,0,0,0}, exploreStyle=-1;
String chatQuestion="", chatAnswer="Hola! Toca Escribir para hablar.";
void loadBrain(){
 if(!prefs.isKey("brain") || prefs.getBytesLength("brain")!=sizeof(brain) || prefs.getBytes("brain",&brain,sizeof(brain))!=sizeof(brain) || brain.magic!=0x50455431){
  memset(&brain,0,sizeof(brain)); brain.magic=0x50455431;
 }
}
void saveBrainIfDue(){
 if(brainDirty && millis()-brainChangedAt>=20000){
  if(prefs.putBytes("brain",&brain,sizeof(brain))==sizeof(brain)) brainDirty=false;
  else brainChangedAt=millis();
 }
}
void trainBrain(bool useful){
 if(!haveAnswer)return;
 for(int i=0;i<5;i++) if(lastFeatures[i]){
  int v=brain.weight[lastStyle][i]+(useful?1:-1)*lastFeatures[i];
  brain.weight[lastStyle][i]=constrain(v,-30,30);
 }
 if(brain.ratings<1000000)brain.ratings++;
 brainDirty=true;brainChangedAt=millis();
 exploreStyle=useful?-1:(lastStyle+1)%3;
 haveAnswer=false; // one rating per answer
}
String ipString(uint32_t a){return String((a>>24)&255)+"."+String((a>>16)&255)+"."+String((a>>8)&255)+"."+String(a&255);}
uint32_t ipNumber(IPAddress a){return ((uint32_t)a[0]<<24)|((uint32_t)a[1]<<16)|((uint32_t)a[2]<<8)|a[3];}
// Shared visual language for the 240 x 320 display.
const uint16_t UI_BG=0x0884, UI_PANEL=0x1148, UI_CARD=0x19AA;
const uint16_t UI_EDGE=0x2ACC, UI_ACCENT=0x3DFF, UI_MUTED=0x9D5A;
void labelRaw(int x,int y,const String& s,uint16_t color=ILI9341_WHITE,int size=1){display.setTextColor(color);display.setTextSize(size);display.setCursor(x,y);display.print(s);}
void label(int x,int y,const String& s,uint16_t color=ILI9341_WHITE,int size=1){labelRaw(x,y,localize(s),color,size);}
void button(int x,int y,int w,int h,const String& text,uint16_t color=UI_CARD){
 display.fillRoundRect(x,y,w,h,5,color);display.drawRoundRect(x,y,w,h,5,UI_EDGE);
 String txt=localize(text);int tx=x+max(4,(w-(int)txt.length()*6)/2);labelRaw(tx,y+(h-8)/2,txt);
}
void header(const String& title, const String& subtitle){
 display.fillRoundRect(4,4,232,36,6,UI_PANEL);
 display.fillRoundRect(9,11,3,20,1,UI_ACCENT);
 label(18,9,title,ILI9341_WHITE,2);label(19,27,subtitle,UI_MUTED);
}
String fitText(const String& text,unsigned maxChars){
 if(text.length()<=maxChars)return text;
 return text.substring(0,maxChars-1)+"~";
}
// Wrap text inside a fixed card without spilling onto controls.
void wrapped(int x,int y,const String& source,int columns,int lines,uint16_t color=ILI9341_WHITE){
 String text=localize(source);int pos=0;for(int line=0;line<lines && pos<(int)text.length();line++){
  while(pos<(int)text.length() && text[pos]==' ')pos++;
  int end=min((int)text.length(),pos+columns);
  if(end<(int)text.length()) {int split=end;while(split>pos && text[split]!=' ')split--;if(split>pos)end=split;}
  String piece=text.substring(pos,end);
  if(line==lines-1 && end<(int)text.length())piece=fitText(piece+"~",columns);
  labelRaw(x,y+line*11,piece,color);pos=end;
 }
}
// Small, frame-based poses are drawn into the existing off-screen canvas.
// Only changed display bands are sent, so animation does not clear the TFT.
uint8_t petFrame=0;
unsigned long lastPetFrame=0;
unsigned long lastAutoAction=0, autoInterval=4000;
const char* moodName(Mood m){
 switch(m){case HAPPY:return "ALEGRE";case WORRIED:return "PREOCUPADO";
 case CURIOUS:return "CURIOSO";case SLEEPY:return "SONOLIENTO";
 case LOVE:return "CARINOSO";case EXCITED:return "EMOCIONADO";
 default:return "TRANQUILO";}
}
void pet(Mood feeling){
 const uint16_t fur=0x6E7C, light=0xFFFF, dark=UI_BG, detail=UI_EDGE;
 const int8_t bounce[12]={0,0,-1,-1,-2,-2,-2,-1,-1,0,0,0};
 const int8_t wave[12]={0,1,2,3,2,1,0,-1,-2,-3,-2,-1};
 int b=bounce[petFrame%12], w=wave[petFrame%12];
 if(feeling==SLEEPY) b=(petFrame%12<6)?0:-1;
 else if(feeling==EXCITED) b*=3; // visible little jumps
 // Shadow, feet, legs and torso. The head remains separate from the body.
 display.fillRoundRect(88,155,64,3,1,UI_CARD);
 display.fillRoundRect(100,139+b,14,14,5,fur);
 display.fillRoundRect(126,139+b,14,14,5,fur);
 display.fillRoundRect(96,148+b,20,7,3,fur);
 display.fillRoundRect(124,148+b,20,7,3,fur);
 // Arms move for excitement and waving; other moods breathe more quietly.
 int handL=feeling==EXCITED? -10-w*2 : feeling==LOVE? -6+w : feeling==CURIOUS? 3+w : w/2;
 int handR=feeling==EXCITED? -10+w*2 : feeling==HAPPY? -9-w*2 : feeling==CURIOUS? 3-w : -w/2;
 display.drawLine(101,119+b,82,129+b+handL,fur);
 display.drawLine(102,120+b,83,130+b+handL,fur);
 display.drawLine(138,119+b,157,129+b+handR,fur);
 display.drawLine(137,120+b,156,130+b+handR,fur);
 display.fillCircle(81,130+b+handL,5,fur);
 display.fillCircle(159,130+b+handR,5,fur);
 display.fillRoundRect(98,109+b,44,36,14,fur);
 display.fillRoundRect(109,119+b,22,19,9,light);
 display.fillCircle(120,127+b,3,detail);
 // Pointed ears and expressive face.
 display.fillTriangle(84,68+b,88,41+b,106,60+b,fur);
 display.fillTriangle(134,60+b,152,41+b,156,68+b,fur);
 display.fillTriangle(89,61+b,91,48+b,99,59+b,detail);
 display.fillTriangle(141,59+b,149,48+b,151,61+b,detail);
 display.fillRoundRect(79,56+b,82,59,22,fur);
 bool blink=feeling==SLEEPY || petFrame%40==0 || petFrame%40==1;
 if(blink){
  display.drawLine(96,81+b,105,83+b,dark);display.drawLine(135,83+b,144,81+b,dark);
 }else if(feeling==HAPPY||feeling==EXCITED){
  display.drawLine(95,84+b,100,79+b,dark);display.drawLine(100,79+b,105,84+b,dark);
  display.drawLine(135,84+b,140,79+b,dark);display.drawLine(140,79+b,145,84+b,dark);
 }else{
  int look=feeling==CURIOUS ? (petFrame%24<12 ? -4 : 4) : 0;
  display.fillRoundRect(96+look,76+b,9,14,4,dark);display.fillRoundRect(135+look,76+b,9,14,4,dark);
  display.fillCircle(99+look,79+b,1,light);display.fillCircle(138+look,79+b,1,light);
 }
 display.fillCircle(90,94+b,3,ILI9341_PINK);display.fillCircle(150,94+b,3,ILI9341_PINK);
 if(feeling==WORRIED){
  display.drawLine(95,71+b,105,74+b,dark);display.drawLine(135,74+b,145,71+b,dark);
  display.drawLine(114,103+b,120,100+b,dark);display.drawLine(120,100+b,126,103+b,dark);
 }else if(feeling==CURIOUS)display.drawCircle(120,100+b,3,dark);
 else if(feeling==HAPPY||feeling==EXCITED||feeling==LOVE){
  display.drawLine(114,99+b,120,103+b,dark);display.drawLine(120,103+b,126,99+b,dark);
 }else display.drawLine(115,100+b,125,100+b,dark);
 if(feeling==LOVE){
  int y=59+b;display.fillCircle(183,y,4,ILI9341_PINK);display.fillCircle(191,y,4,ILI9341_PINK);
  display.fillTriangle(179,y+1,195,y+1,187,y+10,ILI9341_PINK);
 }else if(feeling==SLEEPY)labelRaw(181,54+(petFrame%12<6?0:-2),"zZ",light);
 else if(feeling==EXCITED){display.drawLine(180,63,192,63,ILI9341_YELLOW);display.drawLine(186,57,186,69,ILI9341_YELLOW);}
 else if(feeling==CURIOUS)labelRaw(183,57,"?",ILI9341_YELLOW,2);
}

unsigned alertCount();
void screen(){
 display.fillScreen(UI_BG);
 display.fillRoundRect(4,4,232,27,6,UI_PANEL);
 label(12,10,"NEXO",UI_ACCENT,2);
 display.fillCircle(112,17,3,connected?ILI9341_GREEN:ILI9341_YELLOW);
 label(121,13,connected?"CONECTADO":"SIN WI-FI",connected?ILI9341_GREEN:ILI9341_YELLOW);
 // A quiet halo frames the character without covering its expressions.
 display.drawRoundRect(28,37,184,128,24,UI_EDGE);
 if(alertCount())labelRaw(172,48,String("!")+alertCount(),ILI9341_YELLOW);
 label(37,48,petSleeping?"TOCA PARA DESPERTAR":"TOCAME",UI_MUTED);
 pet(petSleeping?SLEEPY:mood);
 display.fillRoundRect(5,170,230,45,6,UI_PANEL);
 label(12,175,String("NEXO / ")+localize(petSleeping?"DURMIENDO":moodName(mood)),UI_ACCENT);
 wrapped(12,187,petSleeping?String("Tocame para despertar un rato."):petNote,35,1,ILI9341_WHITE);
 wrapped(12,200,message,35,1,UI_MUTED);
 display.fillRoundRect(5,219,230,56,6,UI_PANEL);
 if(connected){
  label(12,225,String("IP  ")+WiFi.localIP().toString());
  label(12,239,String("SENAL  ")+WiFi.RSSI()+" dBm",UI_MUTED);

 }else{
  label(12,227,"Elige una red de 2.4 GHz");
  label(12,239,wifiName.length()?"Reintentando la red guardada":"Toca Wi-Fi para comenzar",UI_MUTED);
 }
 // Satiety bar shares the same card on and off Wi-Fi.
 label(12,255,"HAMBRE",UI_MUTED);
 display.drawRoundRect(59,253,118,10,3,UI_EDGE);
 int fill=(114*satiety)/100;
 if(fill>0)display.fillRoundRect(61,255,fill,6,2,satiety<=30?ILI9341_YELLOW:ILI9341_GREEN);
 labelRaw(183,255,String(satiety)+"%",satiety<=30?ILI9341_YELLOW:ILI9341_WHITE);
 button(3,280,57,35,"Wi-Fi");button(62,280,57,35,"Equipos");
 button(121,280,57,35,"Chat");button(180,280,57,35,"Reloj");
 redraw=false;
}
void react(Mood feeling, const String& note, unsigned long duration=0){
 if(mood!=feeling || petNote!=note){mood=feeling;petNote=note;redraw=true;}
 reactionUntil=duration?millis()+duration:0;
}
void refreshMood(){
 if(reactionUntil && (int32_t)(millis()-reactionUntil)<0)return;
 reactionUntil=0;
 if(!connected)react(WORRIED,wifiName.length()?"Buscare nuestra red guardada.":"Elige una red para empezar.");
 else if(scanning)react(CURIOUS,"Estoy revisando la red...");
 else if(signalQuality==0)react(WORRIED,"La senal Wi-Fi es debil.");
 else react(HAPPY,"Estoy cuidando nuestra red!");
}

// On-device setup; affine calibration accommodates panel-to-panel variations.
enum View { HOME, CALIBRATE, NETWORKS, PASSWORD, CONNECTING, DEVICES, CHAT, CLOCK, SETTINGS, ALERTS, RADIO, RADIO_ENTRY };
View view=HOME;
// Idle actions are local, non-blocking and never navigate or press controls.
void autonomousPet(){
 if(petSleeping || view!=HOME || millis()-lastAutoAction<autoInterval)return;
 // Give touch and real events time to finish before choosing an idle action.
 if(reactionUntil && (int32_t)(millis()-reactionUntil)<0)return;
 // Cycle through distinct actions so random repeats cannot hide variety.
 struct Action { Mood feeling; const char* note; };
 static const Action actions[]={
  {CURIOUS,"Miro a mi alrededor!"},
  {HAPPY,"Hola! Te saludo con la mano!"},
  {EXCITED,"Estoy saltando de alegria!"},
  {LOVE,"Te mando un abrazo!"},
  {CALM,"Respiro y descanso un momento."},
  {CURIOUS,"Que habra por aqui?"},
  {EXCITED,"Tengo ganas de jugar!"}
 };
 static unsigned nextAction=0;
 const unsigned count=sizeof(actions)/sizeof(actions[0]);
 unsigned choice=nextAction++%count;
 lastAutoAction=millis();autoInterval=8000;
 react(actions[choice].feeling,actions[choice].note,6500);
}
float ax=0,bx=0,cx=0,ay=0,by=0,cy=0;
int calStep=0; int rawX[3],rawY[3];
bool touchHeld=false, uiDirty=true, wifiSearching=false, trial=false, showPassword=false;
int networkCount=0, pageStart=0, keyboardPage=0, devicePage=0;
String names[30], chosen, typed, trialPass;
bool openNetwork[30];
String uiNotice="";
unsigned long trialStart=0;
const char* keys[3][4]={
 {"1234567890","qwertyuiop","asdfghjkl-","zxcvbnm_@."},
 {"1234567890","QWERTYUIOP","ASDFGHJKL-","ZXCVBNM_@."},
 {"!#$%&'()*+",",/:;<=>?[]","\\^`{|}~\"_@",".-12345678"}
};
#include "clock_ui.h"
#include "security.h"
#include "subghz.h"

void startWifiScan(){
 if(trial)return;
 // Opening the list while a background scan is running adopts its result.
 if(wifiSearching){view=NETWORKS;uiNotice="Buscando redes...";uiDirty=true;return;}
 WiFi.scanDelete();networkCount=0;pageStart=0;uiNotice="Buscando redes...";
 int rc=WiFi.scanNetworks(true,false);
 wifiSearching=(rc==WIFI_SCAN_RUNNING);if(!wifiSearching)uiNotice="Error al buscar. Reintenta.";
 view=NETWORKS;uiDirty=true;
}
void startScan();
void answerChat(const String& text){
 chatQuestion=text; String q=text; q.toLowerCase();
 bool emotional=q.indexOf("triste")>=0||q.indexOf("mal")>=0||q.indexOf("solo")>=0||q.indexOf("sad")>=0||q.indexOf("lonely")>=0;
 bool greeting=q.indexOf("hola")>=0||q.indexOf("gracias")>=0||q.indexOf("hello")>=0||q.indexOf("thanks")>=0;
 int f[5]={1,connected?1:0,scanning?1:0,emotional?1:0,greeting?1:0};
 for(int i=0;i<5;i++)lastFeatures[i]=f[i];
 int chosenStyle=0, best=-10000;
 for(int j=0;j<3;j++){int score=0;for(int i=0;i<5;i++)score+=brain.weight[j][i]*f[i];if(score>best){best=score;chosenStyle=j;}}
 if(exploreStyle>=0){chosenStyle=exploreStyle;exploreStyle=-1;}
 lastStyle=chosenStyle; haveAnswer=true;
 String result;
 if((q.indexOf("ayuda")>=0||q.indexOf("help")>=0))result="Prueba: estado, ip, senal, equipos, buscar, hola o triste.";
 else if(q.indexOf("buscar")>=0||q.indexOf("escanear")>=0||q.indexOf("scan")>=0){
  if(!connected)result="Conecta Wi-Fi primero.";
  else if(scanning||pingHandle)result="Ya estoy buscando. Mira Equipos.";
  else {startScan();result="Busqueda iniciada. Mira Equipos.";}
 }else if(q.indexOf("equipos")>=0||q.indexOf("dispositivos")>=0||q.indexOf("devices")>=0){
  if(!connected)result="Necesito Wi-Fi para revisar equipos.";
  else if(scanning)result=String("Buscando: ")+pendingCount+" respuestas; "+examined+"/"+total+" revisados.";
  else if(scanDone)result=String(foundCount)+" equipos respondieron. No todos responden.";
  else result="Todavia no tengo resultados.";
 }else if(q.indexOf("senal")>=0||q.indexOf("wifi")>=0||q.indexOf("wi-fi")>=0||q.indexOf("estado")>=0||q.indexOf("ip")>=0||q.indexOf("red")>=0||q.indexOf("status")>=0||q.indexOf("signal")>=0||q.indexOf("network")>=0){
  if(connected)result=String("IP ")+WiFi.localIP().toString()+". Senal "+WiFi.RSSI()+" dBm.";
  else result=wifiName.length()?"Sin conexion. Reintento la red guardada.":"Sin conexion. Toca Wi-Fi para elegir red.";
 }else if(emotional)result="Siento que estes asi. Estoy aqui contigo.";
 else if((q.indexOf("gracias")>=0||q.indexOf("thanks")>=0))result="Me alegra ayudarte!";
 else if(greeting)result="Hola! Que bueno verte.";
 else result="Aun no entiendo eso. Escribe ayuda para ver lo que puedo hacer.";
 if(chosenStyle==1)result="Vamos juntos. "+result;
 else if(chosenStyle==2)result="Te cuento: "+result;
 chatAnswer=result;
 if(emotional)react(WORRIED,"Estoy aqui para escucharte.",7000);
 else if(greeting)react(LOVE,"Me alegra hablar contigo!",7000);
 else react(CURIOUS,"Pensando en tu mensaje...",5000);
}
void showUI(){
 if(view==HOME){screen();present();uiDirty=false;return;}
 display.fillScreen(UI_BG);
 if(view==CALIBRATE){
  label(10,8,"CALIBRAR PANTALLA",ILI9341_CYAN,1);label(10,24,"Toca el centro de cada cruz");
  const int xx[]={20,220,20}, yy[]={55,55,295};
  int x=xx[calStep],y=yy[calStep];display.drawCircle(x,y,10,ILI9341_YELLOW);display.drawLine(x-14,y,x+14,y,ILI9341_YELLOW);display.drawLine(x,y-14,x,y+14,ILI9341_YELLOW);
 }else if(view==CLOCK){clockScreen();
 }else if(view==SETTINGS){
  header("AJUSTES","PERSONALIZA TU EXPERIENCIA");
  label(12,58,"Formato de hora",UI_ACCENT);button(10,76,220,38,clock12?"12 h / AM PM":"24 h");
  label(12,127,"Temperatura",UI_ACCENT);button(10,145,220,38,fahrenheit?"Fahrenheit (F)":"Celsius (C)");
  label(12,196,"Idioma / Language",UI_ACCENT);button(10,214,220,38,english?"English":"Espanol");
  button(3,282,112,32,"Volver");button(124,282,113,32,"Mascota");
 }else if(view==NETWORKS){
  header("WI-FI","REDES CERCANAS / 2.4 GHz");
  label(8,42,fitText(uiNotice,37),ILI9341_YELLOW);
  for(int n=0;n<5 && pageStart+n<networkCount;n++){
   int i=pageStart+n;int y=45+n*39;
   // Keep the original touch row boundaries; inset the visual card.
   display.fillRoundRect(4,y+8,232,29,5,UI_PANEL);display.drawRoundRect(4,y+8,232,29,5,UI_EDGE);
   labelRaw(10,y+10,fitText(names[i],32));label(10,y+23,openNetwork[i]?"ABIERTA":"CON CLAVE",UI_ACCENT);
  }
  button(3,247,74,27,"Anterior");button(82,247,74,27,"Siguiente");button(161,247,76,27,"Buscar");
  button(3,282,112,32,"Mascota");button(124,282,113,32,"Calibrar");
 }else if(view==DEVICES){
  header("EQUIPOS","RED LOCAL / RESPUESTAS");
  String status=!connected?String("Conecta Wi-Fi primero"):scanning?String("Revisados ")+examined+"/"+total+"; respuestas: "+pendingCount:scanDone?String(foundCount)+" respuestas":String("Aun sin resultados");
  label(8,42,fitText(status,37),ILI9341_YELLOW);
  unsigned visibleCount=scanning?pendingCount:foundCount;
  for(int n=0;n<5 && devicePage+n<(int)visibleCount;n++){
   int y=56+n*33;display.fillRoundRect(5,y,230,30,4,UI_PANEL);
   uint32_t ip=scanning?pending[devicePage+n]:found[devicePage+n];
   labelRaw(12,y+5,ipString(ip));label(12,y+17,isKnown(ip)?"CONOCIDA":"POR REVISAR",isKnown(ip)?ILI9341_GREEN:ILI9341_YELLOW);
  }
  label(8,224,"Toca una IP para marcarla.",UI_MUTED);
  label(8,235,"No todos los equipos responden.",UI_MUTED);
  button(3,247,74,27,"Anterior");button(82,247,74,27,"Siguiente");button(161,247,76,27,"Buscar");
  button(3,282,70,32,"Mascota");button(76,282,75,32,"Avisos");button(154,282,83,32,"Radio");
 }else if(view==ALERTS){
  header("AVISOS","HISTORIAL / 8 EVENTOS");
  if(!alerts.count)label(10,54,"Sin avisos guardados.",UI_MUTED);
  for(int i=0;i<alerts.count && i<8;i++){
   int y=45+i*24;display.fillRoundRect(5,y,230,22,4,UI_PANEL);
   labelRaw(10,y+2,alertTime(alerts.entries[i].utc),UI_ACCENT);
   label(10,y+12,fitText(String(alerts.entries[i].text),36),ILI9341_WHITE);
  }
  label(8,245,"Usa WPA2-AES o WPA3.",UI_MUTED);
  label(8,257,"Actualiza router y desactiva WPS.",UI_MUTED);
  label(8,269,"Una IP nueva no prueba intrusion.",ILI9341_YELLOW);
  button(3,282,112,32,"Equipos");button(124,282,113,32,"Mascota");
 }else if(view==RADIO){
  header("SUB-GHZ","SOLO RECEPCION / CC1101");
  button(2,42,57,25,"Scan",radioMode==RF_SCAN?0x2469:UI_PANEL);
  button(62,42,56,25,"Espectro",radioMode==RF_SPECTRUM?0x2469:UI_PANEL);
  button(121,42,56,25,"Cascada",radioMode==RF_WATERFALL?0x2469:UI_PANEL);
  button(180,42,58,25,"Decode",radioMode==RF_DECODE?0x2469:UI_PANEL);
  if(!radioReady){wrapped(10,82,"Receptor no disponible. Revisa el shield y toca Reintentar.",34,4,ILI9341_YELLOW);}
  else if(radioMode==RF_SPECTRUM || radioMode==RF_WATERFALL){
   labelRaw(8,72,String("Centro: ")+formatMHz(radioCenter)+" MHz",UI_ACCENT);
   labelRaw(8,86,String("+/- ")+String((16*rfSteps[rfStepIndex])/1000)+" kHz / filtro 58 kHz",UI_MUTED);
   display.drawRect(7,99,226,107,UI_EDGE);
   // 33 bins put the selected center exactly at the center of the display.
   if(radioMode==RF_SPECTRUM){
    for(int i=0;i<RF_BINS;i++)if(rfBins[i]!=-128){int h=constrain(((int)rfBins[i]-rfNoise)*96/40,0,96);display.fillRect(21+i*6,203-h,5,h,UI_ACCENT);}
   }else{
    const uint16_t colors[]={UI_PANEL,0x2453,UI_ACCENT,ILI9341_GREEN,ILI9341_YELLOW,ILI9341_MAGENTA,ILI9341_WHITE};
    for(int y=0;y<rfRows;y++)for(int x=0;x<RF_BINS;x++){
     int v=rfHistory[y][x];int d=v-rfNoise;
     int level=v==-128?0:d<3?0:d<6?1:d<10?2:d<16?3:d<24?4:d<35?5:6;
     display.fillRect(21+x*6,101+y*2,6,2,colors[level]);
    }
   }
   display.drawFastVLine(120,99,4,ILI9341_YELLOW);
   button(3,210,35,28,"-");
   button(41,210,110,28,String("Paso ")+String(rfSteps[rfStepIndex]/1000.0f,1)+"k");
   button(154,210,35,28,"+");
   labelRaw(193,215,rfNoiseValid?String(rfNoise):String("--"),UI_MUTED);
   labelRaw(193,225,"dBm",UI_MUTED);
  }else if(radioMode==RF_DECODE){
   labelRaw(10,78,formatMHz(radioCenter)+" MHz / ASK-OOK",ILI9341_WHITE,2);
   label(10,111,"Solo protocolos RC-Switch",UI_ACCENT);
   if(decodedCount){
    labelRaw(10,134,String("Candidato RC #")+decodedProtocol+" / "+decodedBits+" bits",ILI9341_YELLOW);
    labelRaw(10,152,String("Codigo: 0x")+String(decodedValue,HEX),ILI9341_WHITE);
    labelRaw(10,166,String("Pulso: ")+decodedDelay+" us  /  recibidos: "+decodedCount,UI_MUTED);
   }else label(10,140,"Esperando un mando compatible...",UI_MUTED);
   labelRaw(10,181,radioHasSample?String("Energia: ")+radioDbm+" dBm":"Energia: --",UI_ACCENT);
   wrapped(10,202,"No P25, DMR ni voz analogica. Posibles falsos positivos.",35,3,UI_MUTED);
  }else if(radioScanning){
   label(10,80,"Buscando mayor energia...",UI_MUTED);
   labelRaw(10,100,formatMHz(radioFrequency)+" MHz",ILI9341_WHITE,2);
   label(10,132,String("Progreso: ")+(radioScanned*100/radioScanTotal)+"%",UI_ACCENT);
   label(10,158,radioBestValid?String("Maximo: ")+formatMHz(radioBestFreq)+" MHz":String("Esperando lectura..."),UI_MUTED);
   label(10,177,radioBestValid?String("Energia: ")+radioBestDbm+" dBm":String(""),UI_MUTED);
   wrapped(10,208,"Puede tardar mas de un minuto. Puede ser ruido.",35,3,ILI9341_YELLOW);
  }else{
   label(10,80,radioAutoResult?"Maxima energia aproximada":"Frecuencia manual",UI_MUTED);
   labelRaw(10,100,formatMHz(radioFrequency)+" MHz",ILI9341_WHITE,2);
   labelRaw(10,139,radioHasSample?String(radioDbm)+" dBm":String("-- dBm"),UI_ACCENT,2);
   label(10,171,radioHasSample?String("Pico: ")+radioPeak+" dBm":String("Esperando lectura..."),UI_MUTED);
   wrapped(10,205,"Es energia, no una identificacion segura.",35,3,ILI9341_YELLOW);
  }
  button(3,247,112,27,"Introducir MHz");button(124,247,113,27,radioReady?"Buscar auto":"Reintentar");
  button(3,282,112,32,"Equipos");button(124,282,113,32,"Mascota");
 }else if(view==RADIO_ENTRY){
  header("FRECUENCIA","INTRODUCIR MHz");
  labelRaw(8,49,radioEntry.length()?radioEntry:String("--"),ILI9341_WHITE,2);
  label(8,76,radioEntryError.length()?radioEntryError:String("300-348 / 387-464 / 779-928"),radioEntryError.length()?ILI9341_YELLOW:UI_MUTED);
  const char* rfKeys[4][3]={{"1","2","3"},{"4","5","6"},{"7","8","9"},{".","0","<"}};
  for(int row=0;row<4;row++)for(int col=0;col<3;col++)button(8+col*75,94+row*36,68,32,rfKeys[row][col]);
  button(3,247,74,27,"Limpiar");button(82,247,74,27,"Cancelar");button(161,247,76,27,"Aceptar",0x2469);
  label(8,287,"Solo receptor / MHz",UI_MUTED);
 }else if(view==CHAT){
  header("CHAT","CONVERSACION LOCAL");
  display.fillRoundRect(5,46,230,48,6,UI_PANEL);label(12,52,"TU",UI_ACCENT);
  wrapped(12,65,chatQuestion.length()?chatQuestion:String("Escribe ayuda para empezar."),35,2);
  display.fillRoundRect(5,100,230,85,6,UI_PANEL);label(12,106,"NEXO",UI_ACCENT);
  wrapped(12,120,chatAnswer,35,5);
  label(8,193,String("Valoraciones: ")+brain.ratings,ILI9341_YELLOW);
  label(8,209,haveAnswer?"Valora esta respuesta una vez":"Escribe para recibir una respuesta",UI_MUTED);
  button(3,231,110,38,"Escribir");button(119,231,57,38,"+ Util",0x2469);button(181,231,56,38,"- No",0x7008);
  button(3,282,112,32,"Mascota");button(124,282,113,32,"Equipos");
 }else if(view==PASSWORD){
  label(5,5,chatInput?"ESCRIBE A NEXO":"CLAVE DE WI-FI",ILI9341_CYAN);String ss=chatInput?String("Mensaje (hasta 63 caracteres)"):chosen;if(ss.length()>29)ss=ss.substring(0,28)+"~";labelRaw(5,19,ss);
  label(5,35,uiNotice.length()?uiNotice:String("Caracteres: ")+typed.length()+" / 63",ILI9341_YELLOW);
  // Show the most recently typed characters when the password exceeds the display width.
  String visible="";
  if(showPassword || chatInput){if(typed.length()>38)visible="<"+typed.substring(typed.length()-38);else visible=typed;}
  else {for(unsigned i=0;i<typed.length()&&i<38;i++)visible+='*';if(typed.length()>38)visible="<"+visible;}
  labelRaw(5,48,visible);
  for(int row=0;row<4;row++)for(int col=0;col<10;col++){
   int x=col*24,y=65+row*40;display.fillRoundRect(x+1,y,22,36,3,UI_PANEL);display.drawRoundRect(x+1,y,22,36,3,UI_EDGE);
   char t[2]={keys[keyboardPage][row][col],0};label(x+7,y+12,t,ILI9341_WHITE,2);
  }
  button(2,231,55,34,"Espacio");button(61,231,55,34,"Borrar");button(120,231,57,34,"ABC/#");button(181,231,57,34,"OK",0x2469);
  label(5,270,keyboardPage==0?"minusculas":keyboardPage==1?"MAYUSCULAS":"simbolos",ILI9341_YELLOW);
  button(3,286,100,30,"Cancelar");if(!chatInput)button(110,286,127,30,showPassword?"Ocultar clave":"Mostrar clave");
 }else if(view==CONNECTING){label(5,12,"CONECTANDO...",ILI9341_CYAN,2);labelRaw(6,50,chosen);label(6,75,"Espera hasta 25 segundos");button(35,130,170,36,"Cancelar");}
 present();uiDirty=false;
}
void calibrate(){view=CALIBRATE;calStep=0;uiDirty=true;}
void saveCal(){
 // Three non-collinear samples map directly to the displayed targets.
 float x0=rawX[0],y0=rawY[0],dx1=rawX[1]-x0,dy1=rawY[1]-y0,dx2=rawX[2]-x0,dy2=rawY[2]-y0;
 float det=dx1*dy2-dx2*dy1;
 if(fabsf(det)<10000){calibrate();return;}
 ax=200*dy2/det;bx=-200*dx2/det;cx=20-ax*x0-bx*y0;
 ay=-240*dy1/det;by=240*dx1/det;cy=55-ay*x0-by*y0;
 prefs.putFloat("tax",ax);prefs.putFloat("tbx",bx);prefs.putFloat("tcx",cx);prefs.putFloat("tay",ay);prefs.putFloat("tby",by);prefs.putFloat("tcy",cy);prefs.putBool("touchcal",true);
 startWifiScan();
}
void startTrial(){
 if(!chosen.length() || (typed.length() && (typed.length()<8 || typed.length()>63))){uiNotice="Clave: 8 a 63 caracteres";uiDirty=true;return;}
 showPassword=false;trialPass=typed;trial=true;trialStart=millis();view=CONNECTING;uiDirty=true;
 WiFi.disconnect();WiFi.begin(chosen.c_str(),trialPass.c_str());
}
void cancelTrial(){
 trial=false;WiFi.disconnect();if(wifiName.length())WiFi.begin(wifiName.c_str(),wifiPass.c_str());lastAttempt=millis();
 view=NETWORKS;uiNotice="Conexion cancelada";uiDirty=true;
}
void touchAction(int x,int y){
 if(view==HOME){if(y>=280){if(x<61)startWifiScan();else if(x<120){devicePage=0;view=DEVICES;uiDirty=true;}else if(x<179){view=CHAT;uiDirty=true;}else{view=CLOCK;uiDirty=true;}}
  else if(x>=50&&x<=190&&y>=44&&y<=165){
   if(petSleeping){
    petSleeping=false;wakeOverride=true;wakeStarted=millis();lastHungerTick=millis();
    react(HAPPY,"Desperte! Vamos a jugar un rato.",7000);
    petFrame=2;lastPetFrame=millis();redraw=true;return;
   }
   // Head and body have separate touch responses, with varied expressions.
   static unsigned headTouches=0, bodyTouches=0;
   if(y<110){
    const char* phrases[]={"Me gustan las caricias!","Hola! Me alegra verte!","Me encanta estar contigo!"};
    Mood feelings[]={LOVE,HAPPY,EXCITED};unsigned i=headTouches++%3;react(feelings[i],phrases[i],7000);
   }else{
    const char* phrases[]={"Que emocion verte!","Que curioso!","Aqui estoy contigo!"};
    Mood feelings[]={EXCITED,CURIOUS,CALM};unsigned i=bodyTouches++%3;react(feelings[i],phrases[i],7000);
   }
   petFrame=2;lastPetFrame=millis();redraw=true;
  }return;}
 if(view==CALIBRATE)return;
 if(view==CLOCK){if(y>=247&&y<277){if(x<120)view=SETTINGS;else {zoneAttempted=false;zoneLastTry=0;zoneError=0;weatherAttempted=false;weatherLastTry=0;if(connected)configTime(0,0,"pool.ntp.org","time.nist.gov");}uiDirty=true;}
  else if(y>=282){if(x<120){view=HOME;redraw=true;uiDirty=true;}else startWifiScan();}return;}
 if(view==SETTINGS){
  if(y>=76&&y<114){clock12=!clock12;prefs.putBool("clock12",clock12);}
  else if(y>=145&&y<183){fahrenheit=!fahrenheit;prefs.putBool("fahrenheit",fahrenheit);}
  else if(y>=214&&y<252){english=!english;prefs.putBool("english",english);}
  else if(y>=282){if(x<120)view=CLOCK;else{view=HOME;redraw=true;}}uiDirty=true;return;
 }
 if(view==NETWORKS){
  if(y>=45&&y<240){int row=(y-45)/39,idx=pageStart+row;if(idx<networkCount){chosen=names[idx];chosenOpen=openNetwork[idx];typed="";showPassword=false;chatInput=false;uiNotice="";keyboardPage=0;if(openNetwork[idx])startTrial();else{view=PASSWORD;uiDirty=true;}}return;}
  if(y>=247&&y<274){if(x<80){pageStart=max(0,pageStart-5);}else if(x<160){if(pageStart+5<networkCount)pageStart+=5;}else startWifiScan();uiDirty=true;return;}
  if(y>=280){if(x<120){view=HOME;redraw=true;}else calibrate();uiDirty=true;}return;
 }
 if(view==DEVICES){
  if(y>=56&&y<221){int idx=devicePage+(y-56)/33;unsigned count=scanning?pendingCount:foundCount;if(idx<(int)count)toggleKnown(scanning?pending[idx]:found[idx]);uiDirty=true;}
  else if(y>=247&&y<274){if(x<80)devicePage=max(0,devicePage-5);else if(x<160){if(devicePage+5<(int)(scanning?pendingCount:foundCount))devicePage+=5;}else if(connected&&!scanning&&!pingHandle)startScan();uiDirty=true;}
  else if(y>=280){if(x<75){view=HOME;redraw=true;}else if(x<153)view=ALERTS;else{view=RADIO;startRadio();}uiDirty=true;}return;
 }
 if(view==ALERTS){if(y>=280){if(x<120)view=DEVICES;else{view=HOME;redraw=true;}uiDirty=true;}return;}
 if(view==RADIO){if(y>=42&&y<68){selectRadioMode(x<60?RF_SCAN:x<120?RF_SPECTRUM:x<180?RF_WATERFALL:RF_DECODE);}else if(y>=210&&y<238&&radioReady&&(radioMode==RF_SPECTRUM||radioMode==RF_WATERFALL)){if(x<40)shiftRadioCenter(-1);else if(x<153)changeRadioStep();else if(x<190)shiftRadioCenter(1);uiDirty=true;}else if(y>=247&&y<274){if(x<120){if(radioReady){decoderStop();radioScanning=false;radioEntry=formatMHz(radioCenter);radioEntryError="";view=RADIO_ENTRY;}}else if(radioReady)startAutoRadio();else startRadio();uiDirty=true;}else if(y>=280){stopRadio();if(x<120)view=DEVICES;else{view=HOME;redraw=true;}uiDirty=true;}return;}
 if(view==RADIO_ENTRY){
  if(y>=94&&y<238){int row=(y-94)/36,col=x/75;if(row<4&&col<3){
   const char chars[4][3]={{'1','2','3'},{'4','5','6'},{'7','8','9'},{'.','0','<'}};
   char c=chars[row][col];if(c=='<'){if(radioEntry.length())radioEntry.remove(radioEntry.length()-1);}
   else if(radioEntry.length()<10 && (c!='.'||radioEntry.indexOf('.')<0))radioEntry+=c;
   radioEntryError="";uiDirty=true;
  }}else if(y>=247&&y<274){
   if(x<80){radioEntry="";radioEntryError="";}
   else if(x<160){view=RADIO;selectRadioMode(radioMode);}
   else {int32_t f=0;
    if(!parseRadioHz(radioEntry,f))radioEntryError="Banda invalida o mas de 5 decimales";
    else{radioCenter=f;view=RADIO;tuneRadio();}
   }uiDirty=true;
  }return;
 }

 if(view==CHAT){
  if(y>=231&&y<269){
   if(x<116){chatInput=true;typed="";keyboardPage=0;uiNotice="";view=PASSWORD;}
   else if(haveAnswer){trainBrain(x<179);chatAnswer=x<179?"Gracias! Aprendere de tu valoracion.":"Gracias. Probare otro estilo.";}
   uiDirty=true;
  }else if(y>=282){if(x<120){view=HOME;redraw=true;}else{devicePage=0;view=DEVICES;}uiDirty=true;}
  return;
 }
 if(view==PASSWORD){
  if(y>=65&&y<225){int row=(y-65)/40,col=x/24;if(row<4&&col<10&&typed.length()<63){typed+=keys[keyboardPage][row][col];uiDirty=true;}return;}
  if(y>=231&&y<265){if(x<60){if(typed.length()<63)typed+=' ';}else if(x<120){if(typed.length())typed.remove(typed.length()-1);}else if(x<180)keyboardPage=(keyboardPage+1)%3;else {if(chatInput){if(typed.length()){answerChat(typed);typed="";chatInput=false;view=CHAT;uiDirty=true;}}else startTrial();return;}uiDirty=true;return;}
  if(y>=286){if(x<105){typed="";showPassword=false;view=chatInput?CHAT:NETWORKS;chatInput=false;}else if(!chatInput)showPassword=!showPassword;uiDirty=true;}return;
 }
 if(view==CONNECTING&&y>=130&&y<170)cancelTrial();
}
void pollTouch(){
 if(!touch.touched()){touchHeld=false;return;}if(touchHeld)return;
 TS_Point p=touch.getPoint();if(p.z<500)return;touchHeld=true;
 lastAutoAction=millis();autoInterval=8000;
 if(view==CALIBRATE){rawX[calStep]=p.x;rawY[calStep]=p.y;calStep++;if(calStep==3)saveCal();else uiDirty=true;return;}
 int x=constrain((int)(ax*p.x+bx*p.y+cx),0,239),y=constrain((int)(ay*p.x+by*p.y+cy),0,319);touchAction(x,y);
}
void pollWifiUI(){
 if(wifiSearching){int n=WiFi.scanComplete();if(n!=WIFI_SCAN_RUNNING){wifiSearching=false;networkCount=0;
  if(n<0)uiNotice="Error al buscar. Reintenta.";else{
   for(int i=0;i<n&&networkCount<30;i++){String ss=WiFi.SSID(i);if(!ss.length())continue;bool dup=false;for(int k=0;k<networkCount;k++)if(names[k]==ss){dup=true;break;}if(dup)continue;
    names[networkCount]=ss;openNetwork[networkCount]=(WiFi.encryptionType(i)==WIFI_AUTH_OPEN);networkCount++;}
   uiNotice=networkCount?String(networkCount)+" redes encontradas":"No se encontraron redes";
  }
  if(hungerScan){
   hungerScan=false;
   // A radio scan sees access points; it does not join or probe them.
   if(n>0){satiety=100;react(HAPPY,"Que rico! Ya comi redes.",7000);}
   else react(WORRIED,"No encontre redes para comer.",7000);
   redraw=true;
  }
  WiFi.scanDelete();if(view==NETWORKS)uiDirty=true;
 }}
 if(trial){
  if(WiFi.status()==WL_CONNECTED){wifiName=chosen;wifiPass=trialPass;openKnown=true;wifiIsOpen=chosenOpen;prefs.putBool("openknown",true);prefs.putBool("wifiopen",wifiIsOpen);prefs.putString("ssid",wifiName);prefs.putString("pw",wifiPass);bool saved=(prefs.getString("ssid","")==wifiName && prefs.getString("pw","")==wifiPass);trial=false;typed="";trialPass="";view=HOME;message=saved?"Wi-Fi conectado y guardado":"Conectado; no se pudo guardar";redraw=true;uiDirty=true;}
  else if(millis()-trialStart>=25000){trial=false;WiFi.disconnect();if(wifiName.length())WiFi.begin(wifiName.c_str(),wifiPass.c_str());lastAttempt=millis();view=PASSWORD;uiNotice="No conecto. Revisa la clave";uiDirty=true;}
 }
}

// Sleep uses the known local clock, not UTC as a guessed local time.
// Fresh weather day/night estimates sunrise; otherwise use 06:00 locally.
void updatePetNeeds(){
 unsigned long nowMs=millis();
 if(wakeOverride && nowMs-wakeStarted>=120000UL)wakeOverride=false;
 time_t utc=time(nullptr);
 if(utc>1700000000 && zoneValid){
  time_t local=utc+zoneOffset;struct tm tmLocal;
  if(gmtime_r(&local,&tmLocal)){
   bool scheduledNight=tmLocal.tm_hour>=22 || tmLocal.tm_hour<6;
   bool freshSky=weatherValid && (nowMs-weatherLastGood<1800000UL);
   // After falling asleep, wait for daylight even if it arrives after 06:00.
   bool night=freshSky ? (petSleeping ? weatherDay==0 : scheduledNight && weatherDay==0) : scheduledNight;
   if(wakeOverride)night=false;
   if(night!=petSleeping){
    petSleeping=night;lastHungerTick=nowMs;reactionUntil=0;
    if(night){scanning=false;react(SLEEPY,"Zzz... hasta el amanecer.");}
    else react(HAPPY,"Buenos dias! Ya desperte.",7000);
    redraw=true;
   }
  }
 }
 // Two points per minute while awake; no catch-up for time powered off.
 if(petSleeping)lastHungerTick=nowMs;
 else if(nowMs-lastHungerTick>=60000UL){
  unsigned long steps=(nowMs-lastHungerTick)/60000UL;
  lastHungerTick+=steps*60000UL;
  satiety=max(0,satiety-(int)min(steps,50UL)*2);redraw=true;
 }
 // Scans are asynchronous, rate-limited, and never navigate away from HOME.
 if(!petSleeping && satiety<=30 && view==HOME && !trial && !wifiSearching &&
    !scanning && !pingHandle && !zoneBusy && !weatherBusy &&
    (connected || !wifiName.length() || nowMs-lastAttempt>=10000UL) &&
    (!hungerAttempted || nowMs-lastHungerAttempt>=300000UL)){
  hungerAttempted=true;lastHungerAttempt=nowMs;
  WiFi.scanDelete();int rc=WiFi.scanNetworks(true,false);
  if(rc==WIFI_SCAN_RUNNING){wifiSearching=true;hungerScan=true;react(CURIOUS,"Tengo hambre. Busco redes...",7000);}
  else react(WORRIED,"No pude buscar redes.",7000);
  redraw=true;
 }
}

void pingEnd(esp_ping_handle_t h,void*){uint32_t replies=0;esp_ping_get_profile(h,ESP_PING_PROF_REPLY,&replies,sizeof(replies));portENTER_CRITICAL(&pingMux);pingReply=replies>0;pingDone=true;portEXIT_CRITICAL(&pingMux);}
void startScan(){
 if(!connected||pingHandle||zoneBusy||weatherBusy)return;
 uint32_t self=ipNumber(WiFi.localIP()), mask=ipNumber(WiFi.subnetMask());
 // Limit probing to at most the local /24, even on larger networks.
 uint32_t capped=mask|0xffffff00UL, network=self&capped, broadcast=network|~capped;
 if(broadcast<=network+1){message="Red demasiado pequena";redraw=true;return;}
 firstIP=network+1;lastIP=broadcast-1;nextIP=firstIP;pendingCount=0;examined=0;total=lastIP-firstIP+1;scanning=true;message="Buscando en la red...";react(CURIOUS,"Estoy revisando la red...");redraw=true;
}
void processScan(){
 if(pingHandle){
  bool done,reply;portENTER_CRITICAL(&pingMux);done=pingDone;reply=pingReply;portEXIT_CRITICAL(&pingMux);
  if(!done)return;
  esp_ping_delete_session(pingHandle);pingHandle=nullptr;
  if(scanning&&connected){examined++;if(reply&&pendingCount<254)pending[pendingCount++]=currentIP;
   // Update in batches, and immediately when a device responds.
   if(reply || examined%4==0){redraw=true;if(view==DEVICES)uiDirty=true;}
  }
 }
 if(!scanning||!connected||zoneBusy||weatherBusy)return;
 while(nextIP<=lastIP && nextIP==ipNumber(WiFi.localIP())){nextIP++;examined++;}
 if(nextIP>lastIP){
 unsigned newlySeen=0;
 for(unsigned i=0;i<pendingCount;i++)if(!isKnown(pending[i])){
  bool previously=false;for(unsigned j=0;j<foundCount;j++)if(found[j]==pending[i]){previously=true;break;}
  if(!haveBaseline || !previously)newlySeen++;
 }
 if(newlySeen)addAlert(String(newlySeen)+(english?" IPs to review":" IP por revisar"));
 foundCount=pendingCount;for(unsigned i=0;i<foundCount;i++)found[i]=pending[i];scanning=false;scanDone=true;lastScan=millis();message="Red revisada";
 if(haveBaseline && foundCount!=previousCount){
  if(foundCount>previousCount)react(CURIOUS,"Hay mas equipos que respondieron.",8000);
  else react(CURIOUS,"Respondieron menos equipos.",8000);
 }
 previousCount=foundCount;haveBaseline=true;redraw=true;if(view==DEVICES)uiDirty=true;return;}
 currentIP=nextIP++;
 esp_ping_config_t cfg=ESP_PING_DEFAULT_CONFIG();cfg.count=1;cfg.timeout_ms=200;cfg.interval_ms=10;
 IP4_ADDR(ip_2_ip4(&cfg.target_addr),(currentIP>>24)&255,(currentIP>>16)&255,(currentIP>>8)&255,currentIP&255);IP_SET_TYPE_VAL(cfg.target_addr,IPADDR_TYPE_V4);
 esp_ping_callbacks_t cb={};cb.on_ping_end=pingEnd;
 portENTER_CRITICAL(&pingMux);pingDone=false;pingReply=false;portEXIT_CRITICAL(&pingMux);
 if(esp_ping_new_session(&cfg,&cb,&pingHandle)!=ESP_OK){scanning=false;message="Error al iniciar busqueda";lastScan=millis();redraw=true;return;}
 if(esp_ping_start(pingHandle)!=ESP_OK){esp_ping_delete_session(pingHandle);pingHandle=nullptr;scanning=false;message="Error al iniciar busqueda";lastScan=millis();redraw=true;}
}
void setup(){
 Serial.begin(115200);pinMode(18,OUTPUT);digitalWrite(18,HIGH);SPI.begin(36,37,35,17);tft.begin();tft.setRotation(2);canvas=new PetCanvas();if(canvas && canvas->getBuffer())target=canvas;pinMode(7,OUTPUT);digitalWrite(7,HIGH);
 zoneQueue=xQueueCreate(1,sizeof(ZoneResult));
 weatherQueue=xQueueCreate(1,sizeof(WeatherResult));
 prefs.begin("xz-text",false);clock12=prefs.isKey("clock12") && prefs.getBool("clock12");fahrenheit=prefs.isKey("fahrenheit") && prefs.getBool("fahrenheit");english=prefs.isKey("english") && prefs.getBool("english");loadBrain();loadSecurity();openKnown=prefs.isKey("openknown") && prefs.getBool("openknown");wifiIsOpen=openKnown && prefs.isKey("wifiopen") && prefs.getBool("wifiopen");touch.begin();touch.setRotation(0);
 ax=prefs.getFloat("tax",0);bx=prefs.getFloat("tbx",0);cx=prefs.getFloat("tcx",0);ay=prefs.getFloat("tay",0);by=prefs.getFloat("tby",0);cy=prefs.getFloat("tcy",0);
 wifiName=prefs.getString("ssid","");wifiPass=prefs.getString("pw","");
 WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(true);if(wifiName.length()){WiFi.begin(wifiName.c_str(),wifiPass.c_str());lastAttempt=millis();message="Conectando Wi-Fi...";}
 if(!prefs.getBool("touchcal",false))calibrate();else {view=HOME;uiDirty=true;}

}
void loop(){
 pollTouch();pollWifiUI();saveBrainIfDue();bool now=WiFi.status()==WL_CONNECTED;
 if(now!=connected){connected=now;redraw=true;if(view==DEVICES||view==CLOCK)uiDirty=true;if(now){configTime(0,0,"pool.ntp.org","time.nist.gov");if(zoneSSID!=WiFi.SSID())zoneValid=false;if(weatherSSID!=WiFi.SSID())weatherValid=false;if(locationSSID!=WiFi.SSID())locationValid=false;zoneAttempted=false;weatherAttempted=false;message="Wi-Fi conectado";scopeKnown();if(openKnown&&wifiIsOpen)addAlert("Red Wi-Fi abierta");weakAlert=false;scanDone=false;foundCount=0;haveBaseline=false;lastScan=millis();signalQuality=WiFi.RSSI() < -75 ? 0 : 1;if(!pingHandle&&!petSleeping)startScan();}else{message="Wi-Fi desconectado";addAlert("Wi-Fi desconectado");scanning=false;scanDone=false;foundCount=0;haveBaseline=false;signalQuality=-1;react(WORRIED,wifiName.length()?"Buscare nuestra red guardada.":"Elige una red para empezar.");}}
 if(!trial&&!wifiSearching&&!connected&&wifiName.length()&&millis()-lastAttempt>20000){WiFi.disconnect();WiFi.begin(wifiName.c_str(),wifiPass.c_str());lastAttempt=millis();}
 processScan();pollClock();pollRadio();updatePetNeeds();
 if(!petSleeping&&connected&&!scanning&&!pingHandle&&!wifiSearching&&!trial&&millis()-lastScan>=300000)startScan();
 if(millis()-lastMoodCheck>=5000){lastMoodCheck=millis();if(connected){static int lastRssi=999;int rssi=WiFi.RSSI();signalQuality=rssi < -75 ? 0 : 1;if(rssi < -78 && !weakAlert){weakAlert=true;addAlert("Senal Wi-Fi debil");}else if(rssi > -70)weakAlert=false;if(rssi!=lastRssi){lastRssi=rssi;redraw=true;}}refreshMood();}
 autonomousPet();
 // Do not animate with direct-to-screen fallback: that would visibly flicker.
 if(view==HOME && canvas && canvas->getBuffer() && millis()-lastPetFrame>=160){
  lastPetFrame=millis();petFrame=(petFrame+1)%120;redraw=true;
 }
 if(redraw&&view==HOME)uiDirty=true;
 if(uiDirty)showUI();
}

Remix this project

Make it yours in one click

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

Open in Schematik