v3: captive portal propio (sin WiFiManager), servidor web embebido para configurar SSID + token + backend desde el móvil, persistencia en /wifi.cfg

This commit is contained in:
javiservices 2026-06-17 08:47:58 +02:00
parent fd47a622cc
commit 6ddc1e20be
2 changed files with 368 additions and 82 deletions

View File

@ -15,7 +15,6 @@ board_build.partitions = partitions.csv
lib_deps = lib_deps =
bodmer/TFT_eSPI@^2.5.43 bodmer/TFT_eSPI@^2.5.43
bblanchon/ArduinoJson@^7.0.4 bblanchon/ArduinoJson@^7.0.4
tzapu/WiFiManager@^2.0.17
bitbank2/AnimatedGIF@^1.4.1 bitbank2/AnimatedGIF@^1.4.1
; User config del display (TFT_eSPI) y WiFi ; User config del display (TFT_eSPI) y WiFi

View File

@ -1,9 +1,10 @@
// ============================================================ // ============================================================
// DummyBot firmware ESP32 v2 (TFT_eSPI + SPIFFS + AnimatedGIF) // DummyBot firmware ESP32 v3
// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240 SPI) // - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240 SPI)
// - 3x TTP223 (Siguiente / Hecho / Snooze) // - 3x TTP223 (Siguiente / Hecho / Snooze)
// - 1x vibrador (GPIO 25 con transistor) // - 1x vibrador (GPIO 25 con transistor)
// - 12 GIFs animados de caritas en SPIFFS (firmware/gifs/) // - 12 GIFs animados en SPIFFS
// - ** Captive portal ** para configurar WiFi + token desde el móvil
// ============================================================ // ============================================================
#include <Arduino.h> #include <Arduino.h>
@ -12,9 +13,10 @@
#include <SPIFFS.h> #include <SPIFFS.h>
#include <AnimatedGIF.h> #include <AnimatedGIF.h>
#include <WiFi.h> #include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <WiFiManager.h>
// -------------------- Pines -------------------- // -------------------- Pines --------------------
#define PIN_BTN_NEXT 26 #define PIN_BTN_NEXT 26
@ -32,22 +34,29 @@
#define FACE_SIZE 80 #define FACE_SIZE 80
const char* WIFI_AP_NAME = "DummyBot-Setup"; const char* WIFI_AP_NAME = "DummyBot-Setup";
const char* WIFI_AP_PASS = "dummybot"; const char* WIFI_AP_PASS = "dummybot"; // >=8 chars (requisito AP)
#ifndef BACKEND_URL #ifndef BACKEND_URL
#define BACKEND_URL "http://157.180.77.232:18080" #define BACKEND_URL "http://157.180.77.232:18080"
#endif #endif
// Token del dispositivo (lo genera la web). Si está vacío, no se autentica. #ifndef DEFAULT_DEVICE_TOKEN
#ifndef DEVICE_TOKEN #define DEFAULT_DEVICE_TOKEN ""
#define DEVICE_TOKEN ""
#endif #endif
#ifndef DEFAULT_DEVICE_NAME
#define DEFAULT_DEVICE_NAME "Mi DummyBot"
#endif
#define WIFI_CFG_PATH "/wifi.cfg"
// -------------------- Estado global -------------------- // -------------------- Estado global --------------------
TFT_eSPI tft = TFT_eSPI(); TFT_eSPI tft = TFT_eSPI();
AnimatedGIF gif; AnimatedGIF gif;
WebServer web(80);
DNSServer dnsServer;
// Cara actual // Cara
enum Emotion { enum Emotion {
EMO_NEUTRAL = 1, EMO_HAPPY, EMO_WORRIED, EMO_SAD, EMO_CRY, EMO_NEUTRAL = 1, EMO_HAPPY, EMO_WORRIED, EMO_SAD, EMO_CRY,
EMO_SURPRISED, EMO_ANGRY, EMO_SLEEPY, EMO_WINK, EMO_LOVE, EMO_SURPRISED, EMO_ANGRY, EMO_SLEEPY, EMO_WINK, EMO_LOVE,
@ -66,6 +75,13 @@ uint32_t lastSeenId = 0;
Emotion currentEmotion = EMO_NEUTRAL; Emotion currentEmotion = EMO_NEUTRAL;
char currentGifName[32]; char currentGifName[32];
// Config WiFi + backend + token (se carga de /wifi.cfg al arrancar)
char cfgSsid[64] = "";
char cfgPass[64] = "";
char cfgToken[80] = DEFAULT_DEVICE_TOKEN;
char cfgBackend[128] = BACKEND_URL;
char cfgName[40] = DEFAULT_DEVICE_NAME;
// GIF rendering // GIF rendering
int16_t gif_x = 0, gif_y = 0; int16_t gif_x = 0, gif_y = 0;
bool gifOpen = false; bool gifOpen = false;
@ -81,6 +97,12 @@ uint32_t lastBtnMs[3] = {0,0,0};
const uint8_t BTN_PINS[3] = { PIN_BTN_NEXT, PIN_BTN_DONE, PIN_BTN_SNOOZE }; const uint8_t BTN_PINS[3] = { PIN_BTN_NEXT, PIN_BTN_DONE, PIN_BTN_SNOOZE };
#define DEBOUNCE_MS 200 #define DEBOUNCE_MS 200
// Estado de conexión WiFi
enum WifiState { WIFI_BOOTING, WIFI_PORTAL, WIFI_CONNECTING, WIFI_CONNECTED, WIFI_FAILED };
WifiState wifiState = WIFI_BOOTING;
uint32_t wifiConnectStart = 0;
uint32_t lastPortalTry = 0;
// -------------------- GIF callbacks -------------------- // -------------------- GIF callbacks --------------------
static void *gif_open_cb(const char *fname, int32_t *pSize) { static void *gif_open_cb(const char *fname, int32_t *pSize) {
fs::File *f = new fs::File(SPIFFS.open(fname, "r")); fs::File *f = new fs::File(SPIFFS.open(fname, "r"));
@ -108,20 +130,17 @@ static void gif_draw_callback(GIFDRAW *pDraw) {
int16_t x = pDraw->iX + gif_x; int16_t x = pDraw->iX + gif_x;
int16_t y = pDraw->iY + gif_y; int16_t y = pDraw->iY + gif_y;
int16_t w = pDraw->iWidth; int16_t w = pDraw->iWidth;
if (pDraw->iY == 0) { if (pDraw->iY == 0) {
tft.startWrite(); tft.startWrite();
tft.setAddrWindow(x, y, w, 1); tft.setAddrWindow(x, y, w, 1);
} }
tft.pushColors(pixels, w, false); tft.pushColors(pixels, w, false);
if (pDraw->iY == pDraw->iHeight - 1) { if (pDraw->iY == pDraw->iHeight - 1) {
tft.endWrite(); tft.endWrite();
} }
} }
// -------------------- Vib & Tareas -------------------- // -------------------- Vib --------------------
static void startVib() { static void startVib() {
vibActive = true; vibActive = true;
vibStart = millis(); vibStart = millis();
@ -160,7 +179,6 @@ static void emotionToGif(Emotion e, char *out, size_t sz) {
out[sz - 1] = '\0'; out[sz - 1] = '\0';
} }
// Mapeo emoción según prioridad
static Emotion emotionForTask(const Task *t) { static Emotion emotionForTask(const Task *t) {
if (!t) return EMO_NEUTRAL; if (!t) return EMO_NEUTRAL;
if (strcmp(t->priority, "high") == 0) return EMO_WORKING; if (strcmp(t->priority, "high") == 0) return EMO_WORKING;
@ -200,7 +218,6 @@ static void drawTaskScreen() {
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
drawHeader(); drawHeader();
// Banda de prioridad
uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED : uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED :
(strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW; (strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW;
tft.fillRect(0, 22, 240, 12, c); tft.fillRect(0, 22, 240, 12, c);
@ -210,7 +227,6 @@ static void drawTaskScreen() {
String prio = String("PRIORIDAD ") + currentTask.priority; String prio = String("PRIORIDAD ") + currentTask.priority;
tft.drawString(prio, 120, 24, 2); tft.drawString(prio, 120, 24, 2);
// Título (debajo de la cara que está en la esquina)
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2); tft.setTextSize(2);
tft.setTextDatum(TL_DATUM); tft.setTextDatum(TL_DATUM);
@ -229,19 +245,30 @@ static void drawTaskScreen() {
tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
int mins = currentTask.next_due_in / 60; int mins = currentTask.next_due_in / 60;
String due = "Vence en "; String due = "Vence en ";
if (mins >= 60) { if (mins >= 60) due += String(mins / 60) + "h " + String(mins % 60) + "m";
due += String(mins / 60) + "h " + String(mins % 60) + "m"; else if (mins > 0) due += String(mins) + " min";
} else if (mins > 0) { else due += "ahora";
due += String(mins) + " min";
} else {
due += "ahora";
}
tft.drawString(due, 8, 195, 2); tft.drawString(due, 8, 195, 2);
} }
drawFooter("Snooze | Hecha | Siguiente"); drawFooter("Snooze | Hecha | Siguiente");
} }
static void drawEmotionFallback(Emotion e) {
static const char* labels[] = {
"OK", "HA", "WO", "SA", "CR", "SU", "AN", "SL", "WI", "LO", "WK", "EX"
};
int idx = (int)e - 1;
if (idx < 0 || idx >= 12) idx = 0;
tft.fillRect(gif_x, gif_y, FACE_SIZE, FACE_SIZE, TFT_NAVY);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(4);
tft.drawString(labels[idx], gif_x + FACE_SIZE / 2, gif_y + FACE_SIZE / 2 - 10, 4);
tft.setTextSize(1);
tft.drawString(currentGifName, gif_x + FACE_SIZE / 2, gif_y + FACE_SIZE / 2 + 18, 1);
}
// -------------------- GIF control -------------------- // -------------------- GIF control --------------------
static void setEmotion(Emotion e) { static void setEmotion(Emotion e) {
if (e == currentEmotion && gifOpen) return; if (e == currentEmotion && gifOpen) return;
@ -249,10 +276,7 @@ static void setEmotion(Emotion e) {
emotionToGif(e, name, sizeof(name)); emotionToGif(e, name, sizeof(name));
if (strcmp(name, currentGifName) == 0 && gifOpen) return; if (strcmp(name, currentGifName) == 0 && gifOpen) return;
if (gifOpen) { if (gifOpen) { gif.close(); gifOpen = false; }
gif.close();
gifOpen = false;
}
strncpy(currentGifName, name, sizeof(currentGifName) - 1); strncpy(currentGifName, name, sizeof(currentGifName) - 1);
char path[40]; char path[40];
@ -264,15 +288,16 @@ static void setEmotion(Emotion e) {
tft.fillRect(gif_x, gif_y, FACE_SIZE, FACE_SIZE, TFT_WHITE); tft.fillRect(gif_x, gif_y, FACE_SIZE, FACE_SIZE, TFT_WHITE);
if (gif.open(path, gif_open_cb, gif_close_cb, if (gif.open(path, gif_open_cb, gif_close_cb,
gif_read_cb, gif_seek_cb, gif_draw_callback)) { gif_read_cb, gif_seek_cb, gif_draw_callback)) {
Serial.printf("GIF abierto: %s\n", path);
gifOpen = true; gifOpen = true;
currentEmotion = e; currentEmotion = e;
nextGifMs = millis(); nextGifMs = millis();
} else { } else {
Serial.printf("GIF no se pudo abrir: %s\n", path); drawEmotionFallback(e);
currentEmotion = e;
} }
} else { } else {
Serial.printf("No se encuentra %s en SPIFFS\n", path); drawEmotionFallback(e);
currentEmotion = e;
} }
} }
@ -288,14 +313,14 @@ static void updateGif() {
} }
} }
// -------------------- HTTP -------------------- // -------------------- HTTP cliente --------------------
static bool httpGetJson(const char *url, JsonDocument &doc) { static bool httpGetJson(const char *url, JsonDocument &doc) {
if (WiFi.status() != WL_CONNECTED) return false; if (WiFi.status() != WL_CONNECTED) return false;
HTTPClient http; HTTPClient http;
http.begin(url); http.begin(url);
http.setTimeout(API_TIMEOUT_MS); http.setTimeout(API_TIMEOUT_MS);
if (strlen(DEVICE_TOKEN) > 0) { if (strlen(cfgToken) > 0) {
http.addHeader("Authorization", "Bearer " DEVICE_TOKEN); http.addHeader("Authorization", String("Bearer ") + cfgToken);
} }
int code = http.GET(); int code = http.GET();
if (code != 200) { http.end(); return false; } if (code != 200) { http.end(); return false; }
@ -309,18 +334,19 @@ static bool httpPostJson(const char *url, const char *body) {
http.begin(url); http.begin(url);
http.setTimeout(API_TIMEOUT_MS); http.setTimeout(API_TIMEOUT_MS);
http.addHeader("Content-Type", "application/json"); http.addHeader("Content-Type", "application/json");
if (strlen(DEVICE_TOKEN) > 0) { if (strlen(cfgToken) > 0) {
http.addHeader("Authorization", "Bearer " DEVICE_TOKEN); http.addHeader("Authorization", String("Bearer ") + cfgToken);
} }
int code = http.POST(body); int code = http.POST(body);
http.end(); http.end();
return code >= 200 && code < 300; return code >= 200 && code < 300;
} }
// -------------------- Polling / Acciones -------------------- // -------------------- Lógica de tareas --------------------
static void pollTasks() { static void pollTasks() {
if (WiFi.status() != WL_CONNECTED) return;
char url[160]; char url[160];
snprintf(url, sizeof(url), "%s/api/device/tasks/pending", BACKEND_URL); snprintf(url, sizeof(url), "%s/api/device/tasks/pending", cfgBackend);
JsonDocument doc; JsonDocument doc;
if (!httpGetJson(url, doc)) return; if (!httpGetJson(url, doc)) return;
@ -352,7 +378,7 @@ static void pollTasks() {
static void actionDone() { static void actionDone() {
if (!hasTask) return; if (!hasTask) return;
char url[160], body[8]; char url[160], body[8];
snprintf(url, sizeof(url), "%s/api/device/tasks/%d/done", BACKEND_URL, currentTask.id); snprintf(url, sizeof(url), "%s/api/device/tasks/%d/done", cfgBackend, currentTask.id);
snprintf(body, sizeof(body), "{}"); snprintf(body, sizeof(body), "{}");
if (httpPostJson(url, body)) { if (httpPostJson(url, body)) {
lastSeenId = 0; lastSeenId = 0;
@ -364,7 +390,7 @@ static void actionDone() {
static void actionSnooze() { static void actionSnooze() {
if (!hasTask) return; if (!hasTask) return;
char url[160], body[40]; char url[160], body[40];
snprintf(url, sizeof(url), "%s/api/device/tasks/%d/snooze", BACKEND_URL, currentTask.id); snprintf(url, sizeof(url), "%s/api/device/tasks/%d/snooze", cfgBackend, currentTask.id);
snprintf(body, sizeof(body), "{\"minutes\":%d}", SNOOZE_MINUTES); snprintf(body, sizeof(body), "{\"minutes\":%d}", SNOOZE_MINUTES);
if (httpPostJson(url, body)) { if (httpPostJson(url, body)) {
lastSeenId = 0; lastSeenId = 0;
@ -377,6 +403,7 @@ static void actionNext() { pollTasks(); }
// -------------------- Botones -------------------- // -------------------- Botones --------------------
static void handleButtons() { static void handleButtons() {
if (wifiState == WIFI_PORTAL) return; // no atender botones durante config
uint32_t now = millis(); uint32_t now = millis();
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
if (digitalRead(BTN_PINS[i]) == HIGH && (now - lastBtnMs[i] > DEBOUNCE_MS)) { if (digitalRead(BTN_PINS[i]) == HIGH && (now - lastBtnMs[i] > DEBOUNCE_MS)) {
@ -390,18 +417,272 @@ static void handleButtons() {
} }
} }
// -------------------- Persistencia de config --------------------
static bool loadConfig() {
if (!SPIFFS.exists(WIFI_CFG_PATH)) return false;
fs::File f = SPIFFS.open(WIFI_CFG_PATH, "r");
if (!f) return false;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) return false;
strncpy(cfgSsid, doc["ssid"] | "", sizeof(cfgSsid) - 1);
strncpy(cfgPass, doc["pass"] | "", sizeof(cfgPass) - 1);
strncpy(cfgToken, doc["token"] | DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1);
strncpy(cfgBackend, doc["backend"] | BACKEND_URL, sizeof(cfgBackend) - 1);
strncpy(cfgName, doc["name"] | DEFAULT_DEVICE_NAME, sizeof(cfgName) - 1);
return strlen(cfgSsid) > 0;
}
static void saveConfig() {
JsonDocument doc;
doc["ssid"] = cfgSsid;
doc["pass"] = cfgPass;
doc["token"] = cfgToken;
doc["backend"] = cfgBackend;
doc["name"] = cfgName;
fs::File f = SPIFFS.open(WIFI_CFG_PATH, "w");
if (!f) {
Serial.println("ERROR abriendo /wifi.cfg para escritura");
return;
}
serializeJson(doc, f);
f.close();
Serial.println("Config guardada en SPIFFS");
}
static void resetConfig() {
if (SPIFFS.exists(WIFI_CFG_PATH)) SPIFFS.remove(WIFI_CFG_PATH);
}
// -------------------- Captive portal --------------------
static const char PORTAL_HTML[] PROGMEM = R"=====(<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DummyBot - Configuración</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:#0a0a0f;color:#f4f4f5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem}
.wrap{max-width:480px;width:100%}
.card{background:#18181b;border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:1.5rem;box-shadow:0 20px 60px rgba(0,0,0,.5)}
h1{margin:0 0 .25rem;font-size:1.5rem;background:linear-gradient(135deg,#818cf8,#c084fc);-webkit-background-clip:text;background-clip:text;color:transparent}
p.lead{margin:0 0 1.5rem;color:#a1a1aa;font-size:.9rem}
label{display:block;font-size:.75rem;color:#a1a1aa;margin-bottom:.25rem;margin-top:.85rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em}
input,select{width:100%;padding:.6rem .8rem;background:#09090b;border:1px solid rgba(255,255,255,0.08);color:#f4f4f5;border-radius:8px;font-size:1rem;font-family:inherit}
input:focus{outline:0;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.2)}
button{margin-top:1.25rem;width:100%;padding:.7rem;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;border:none;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer}
button:hover{transform:translateY(-1px);box-shadow:0 10px 30px rgba(99,102,241,.4)}
button:active{transform:translateY(0)}
.help{font-size:.7rem;color:#71717a;margin-top:.25rem}
.divider{height:1px;background:rgba(255,255,255,0.06);margin:1.25rem 0}
.row{display:flex;gap:.5rem;margin-top:.5rem}
.row input{flex:1}
.status{padding:.5rem .75rem;border-radius:6px;font-size:.8rem;margin-bottom:1rem}
.status.warn{background:rgba(234,179,8,.1);color:#fde68a;border:1px solid rgba(234,179,8,.3)}
.status.info{background:rgba(99,102,241,.1);color:#c7d2fe;border:1px solid rgba(99,102,241,.3)}
@keyframes spin{to{transform:rotate(360deg)}}
.spinner{display:inline-block;width:1rem;height:1rem;border:2px solid #6366f1;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:.4rem}
.emoji{font-size:2.5rem;margin-bottom:.5rem}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="emoji">🤖</div>
<h1>DummyBot</h1>
<p class="lead">Configura tu bot. Solo lo harás una vez.</p>
<div class="status info">
<strong>📡 Estás conectado al bot.</strong> Vuelve a conectarte a tu WiFi normal después de guardar.
</div>
<form method="POST" action="/save">
<label>Nombre del bot</label>
<input type="text" name="name" value="__NAME__" required maxlength="40">
<div class="help">Para distinguirlo si tienes varios</div>
<label>Nombre de tu WiFi (SSID)</label>
<input type="text" name="ssid" value="" required maxlength="32" autofocus>
<div class="help">Solo redes 2.4 GHz (las 5 GHz no las soporta el ESP32)</div>
<label>Contraseña WiFi</label>
<input type="password" name="pass" value="" maxlength="64">
<label>URL del servidor DummyBot</label>
<input type="url" name="backend" value="__BACKEND__" required>
<div class="help">Donde está tu Laravel (ej: https://dummybot.example.com)</div>
<label>Token del dispositivo</label>
<input type="text" name="token" value="__TOKEN__" required maxlength="80">
<div class="help">Lo copias en la web: <strong>Ajustes → Dispositivos → Ver token</strong></div>
<button type="submit">💾 Guardar y conectar</button>
</form>
<div class="divider"></div>
<form method="POST" action="/reset" onsubmit="return confirm('¿Borrar config y volver al portal?')">
<button type="submit" style="background:transparent;border:1px solid rgba(239,68,68,.4);color:#fca5a5">🗑 Borrar configuración</button>
</form>
</div>
</div>
</body>
</html>)=====";
static String renderPortal() {
String html = FPSTR(PORTAL_HTML);
html.replace("__NAME__", String(cfgName));
html.replace("__BACKEND__", String(cfgBackend));
html.replace("__TOKEN__", String(cfgToken));
return html;
}
// Redirige cualquier DNS al portal (captive portal style)
static void handleCaptive() {
web.sendHeader("Location", "http://192.168.4.1/");
web.send(302, "text/plain", "");
}
static void handleRoot() {
web.send(200, "text/html; charset=utf-8", renderPortal());
}
static void handleSave() {
if (!web.hasArg("ssid") || !web.hasArg("backend") || !web.hasArg("token")) {
web.send(400, "text/html", "<h1>Falta SSID, backend o token</h1>");
return;
}
String s = web.arg("ssid");
String p = web.arg("pass");
String t = web.arg("token");
String b = web.arg("backend");
String n = web.hasArg("name") ? web.arg("name") : DEFAULT_DEVICE_NAME;
s.trim(); p.trim(); t.trim(); b.trim(); n.trim();
if (s.length() == 0) { web.send(400, "text/html", "<h1>SSID vacío</h1>"); return; }
strncpy(cfgSsid, s.c_str(), sizeof(cfgSsid) - 1);
strncpy(cfgPass, p.c_str(), sizeof(cfgPass) - 1);
strncpy(cfgToken, t.c_str(), sizeof(cfgToken) - 1);
strncpy(cfgBackend, b.c_str(), sizeof(cfgBackend) - 1);
strncpy(cfgName, n.c_str(), sizeof(cfgName) - 1);
saveConfig();
String ok = R"=====(<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DummyBot · Guardado</title><style>
body{margin:0;font-family:-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem}
.card{background:#18181b;border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:2rem;max-width:480px;text-align:center}
h1{color:#86efac;margin:0 0 1rem;font-size:2rem}
p{color:#a1a1aa;margin:0 0 .5rem}
.emoji{font-size:4rem;margin-bottom:1rem}
.next{color:#818cf8;font-weight:600;margin-top:1.5rem}
</style></head><body>
<div class="card">
<div class="emoji">✅</div>
<h1>Guardado</h1>
<p>El bot se está reiniciando para conectarse a tu WiFi.</p>
<p class="next">👉 Vuelve a la lista de WiFi y conectate a <strong>__SSID__</strong></p>
<p style="margin-top:1.5rem;font-size:.85rem">El bot aparecerá como conectado en la web cuando termine de arrancar (~15 s).</p>
</div></body></html>)=====";
ok.replace("__SSID__", s);
web.send(200, "text/html; charset=utf-8", ok);
delay(500);
Serial.println("Reiniciando tras save...");
ESP.restart();
}
static void handleReset() {
resetConfig();
web.sendHeader("Location", "/");
web.send(302);
delay(500);
ESP.restart();
}
static void handleNotFound() {
// Captive portal: cualquier URL redirige al root
handleCaptive();
}
static void setupPortal() {
WiFi.mode(WIFI_AP);
WiFi.softAP(WIFI_AP_NAME, WIFI_AP_PASS);
Serial.printf("AP abierto: %s (pass %s)\n", WIFI_AP_NAME, WIFI_AP_PASS);
Serial.printf("IP del AP: %s\n", WiFi.softAPIP().toString().c_str());
// DNS server: redirige TODO al portal (captive portal)
dnsServer.start(53, "*", WiFi.softAPIP());
// Endpoints
web.on("/", HTTP_GET, handleRoot);
web.on("/save", HTTP_POST, handleSave);
web.on("/reset", HTTP_POST, handleReset);
web.onNotFound(handleNotFound);
web.begin();
Serial.println("Servidor web del portal en http://192.168.4.1/");
}
// -------------------- WiFi conectar --------------------
static void startWifiConnect() {
wifiState = WIFI_CONNECTING;
wifiConnectStart = millis();
Serial.printf("Conectando a WiFi '%s'...\n", cfgSsid);
WiFi.mode(WIFI_STA);
WiFi.begin(cfgSsid, cfgPass);
}
static void updateWifi() {
if (wifiState == WIFI_CONNECTING) {
if (WiFi.status() == WL_CONNECTED) {
wifiState = WIFI_CONNECTED;
Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str());
Serial.printf("Backend: %s\n", cfgBackend);
Serial.printf("Bot: %s\n", cfgName);
// Render de carita feliz + intentar primer poll
setEmotion(EMO_HAPPY);
drawTaskScreen(); // limpia
drawIdle();
pollTasks();
lastPoll = millis();
} else if (millis() - wifiConnectStart > 20000) {
wifiState = WIFI_FAILED;
Serial.println("WiFi falló tras 20 s");
}
} else if (wifiState == WIFI_FAILED) {
// Volvemos al portal
wifiState = WIFI_PORTAL;
setupPortal();
}
}
// -------------------- Demo loop (sin tareas) --------------------
static void demoLoop() {
static uint32_t lastDemo = 0;
static int demo_idx = 0;
const Emotion demo[] = { EMO_HAPPY, EMO_SURPRISED, EMO_WORRIED, EMO_SLEEPY,
EMO_LOVE, EMO_ANGRY, EMO_CRY, EMO_EXCITED };
uint32_t now = millis();
if (!hasTask && now - lastDemo > 3000) {
lastDemo = now;
demo_idx = (demo_idx + 1) % 8;
setEmotion(demo[demo_idx]);
}
}
// -------------------- Setup / Loop -------------------- // -------------------- Setup / Loop --------------------
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(200); delay(200);
Serial.println("\n=== DummyBot v2 arrancando ==="); Serial.println("\n=== DummyBot v3 arrancando ===");
pinMode(PIN_BTN_NEXT, INPUT); pinMode(PIN_BTN_NEXT, INPUT);
pinMode(PIN_BTN_DONE, INPUT); pinMode(PIN_BTN_DONE, INPUT);
pinMode(PIN_BTN_SNOOZE, INPUT); pinMode(PIN_BTN_SNOOZE, INPUT);
pinMode(PIN_VIBRATOR, OUTPUT); pinMode(PIN_VIBRATOR, OUTPUT);
digitalWrite(PIN_VIBRATOR, LOW); digitalWrite(PIN_VIBRATOR, LOW);
pinMode(PIN_TFT_BL, OUTPUT); pinMode(PIN_TFT_BL, OUTPUT);
digitalWrite(PIN_TFT_BL, HIGH); digitalWrite(PIN_TFT_BL, HIGH);
@ -409,7 +690,6 @@ void setup() {
tft.setRotation(0); tft.setRotation(0);
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
// SPIFFS
if (!SPIFFS.begin(true)) { if (!SPIFFS.begin(true)) {
Serial.println("ERROR: SPIFFS mount failed"); Serial.println("ERROR: SPIFFS mount failed");
} else { } else {
@ -419,64 +699,71 @@ void setup() {
drawIdle(); drawIdle();
// Modo demo controlado por una constante de compile // Pantalla de bienvenida mientras carga
#ifdef DUMMYBOT_DEMO
Serial.println("DUMMYBOT_DEMO: saltamos WiFi, modo demo de emociones");
setEmotion(EMO_HAPPY);
lastPoll = millis() + 60000; // no pollear
#else
// Mensaje "configurando WiFi" en pantalla
tft.fillScreen(TFT_BLACK); tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextDatum(MC_DATUM); tft.setTextDatum(MC_DATUM);
tft.setTextSize(2); tft.setTextSize(2);
tft.drawString("WiFi: conecta", 120, 90, 2); tft.drawString("DummyBot", 120, 90, 4);
tft.setTextSize(1); tft.setTextSize(1);
tft.drawString("AP: DummyBot-Setup", 120, 130, 2); tft.drawString("Iniciando...", 120, 130, 2);
tft.drawString("pass: dummybot", 120, 145, 2);
drawFooter("192.168.4.1");
WiFiManager wm; // ¿Hay config guardada?
wm.setConfigPortalTimeout(60); bool haveCfg = loadConfig();
wm.setAPCallback([](WiFiManager *) { if (haveCfg) {
Serial.printf("AP abierto DummyBot-Setup, IP: %s\n", Serial.printf("Config cargada: SSID='%s', Bot='%s'\n", cfgSsid, cfgName);
WiFi.softAPIP().toString().c_str()); startWifiConnect();
}); // Pantalla de "conectando..."
if (!wm.autoConnect(WIFI_AP_NAME, WIFI_AP_PASS)) { tft.fillScreen(TFT_BLACK);
Serial.println("WiFi falló -> seguimos sin red"); tft.setTextDatum(MC_DATUM);
setEmotion(EMO_WORRIED); tft.setTextSize(2);
tft.drawString("WiFi...", 120, 100, 2);
tft.setTextSize(1);
tft.drawString(cfgSsid, 120, 130, 2);
} else { } else {
Serial.printf("WiFi OK, IP: %s\n", WiFi.localIP().toString().c_str()); Serial.println("Sin config, abro portal...");
setEmotion(EMO_HAPPY); wifiState = WIFI_PORTAL;
pollTasks(); setupPortal();
} }
lastPoll = millis();
#endif
} }
void loop() { void loop() {
// Servidor web (portal) si estamos en modo AP
if (wifiState == WIFI_PORTAL) {
dnsServer.processNextRequest();
web.handleClient();
// Dibujar pantalla de "configurando"
static uint32_t lastPortalScreen = 0;
if (millis() - lastPortalScreen > 1500) {
lastPortalScreen = millis();
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(2);
tft.drawString("Config", 120, 80, 2);
tft.setTextSize(1);
tft.drawString("WiFi: DummyBot-Setup", 120, 115, 2);
tft.drawString("Pass: dummybot", 120, 135, 2);
tft.drawString("Abre 192.168.4.1", 120, 160, 2);
drawFooter("portal cautivo activo");
}
return;
}
updateWifi();
handleButtons(); handleButtons();
updateVib(); updateVib();
updateGif(); updateGif();
uint32_t now = millis(); uint32_t now = millis();
// Demo: rotar emociones cada 3 s si no hay tarea real if (wifiState == WIFI_CONNECTED) {
if (!hasTask) { if (now - lastPoll >= POLL_MS) {
static uint32_t lastDemo = 0; lastPoll = now;
static int demo_idx = 0; pollTasks();
const Emotion demo[] = { EMO_HAPPY, EMO_SURPRISED, EMO_WORRIED, EMO_SLEEPY,
EMO_LOVE, EMO_ANGRY, EMO_CRY, EMO_EXCITED };
if (now - lastDemo > 3000) {
lastDemo = now;
demo_idx = (demo_idx + 1) % 8;
setEmotion(demo[demo_idx]);
} }
} }
if (now - lastPoll >= POLL_MS) { demoLoop();
lastPoll = now;
pollTasks();
}
delay(20); delay(20);
} }