From 6ddc1e20be64c0ffd7bf9f0ace65ea9ccbad7562 Mon Sep 17 00:00:00 2001 From: javiservices Date: Wed, 17 Jun 2026 08:47:58 +0200 Subject: [PATCH] =?UTF-8?q?v3:=20captive=20portal=20propio=20(sin=20WiFiMa?= =?UTF-8?q?nager),=20servidor=20web=20embebido=20para=20configurar=20SSID?= =?UTF-8?q?=20+=20token=20+=20backend=20desde=20el=20m=C3=B3vil,=20persist?= =?UTF-8?q?encia=20en=20/wifi.cfg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platformio.ini | 1 - src/main.cpp | 449 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 368 insertions(+), 82 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8edf25c..5ffea4b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,7 +15,6 @@ board_build.partitions = partitions.csv lib_deps = bodmer/TFT_eSPI@^2.5.43 bblanchon/ArduinoJson@^7.0.4 - tzapu/WiFiManager@^2.0.17 bitbank2/AnimatedGIF@^1.4.1 ; User config del display (TFT_eSPI) y WiFi diff --git a/src/main.cpp b/src/main.cpp index 8452c72..28714cf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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) // - 3x TTP223 (Siguiente / Hecho / Snooze) // - 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 @@ -12,9 +13,10 @@ #include #include #include +#include +#include #include #include -#include // -------------------- Pines -------------------- #define PIN_BTN_NEXT 26 @@ -32,22 +34,29 @@ #define FACE_SIZE 80 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 #define BACKEND_URL "http://157.180.77.232:18080" #endif -// Token del dispositivo (lo genera la web). Si está vacío, no se autentica. -#ifndef DEVICE_TOKEN -#define DEVICE_TOKEN "" +#ifndef DEFAULT_DEVICE_TOKEN +#define DEFAULT_DEVICE_TOKEN "" #endif +#ifndef DEFAULT_DEVICE_NAME +#define DEFAULT_DEVICE_NAME "Mi DummyBot" +#endif + +#define WIFI_CFG_PATH "/wifi.cfg" + // -------------------- Estado global -------------------- TFT_eSPI tft = TFT_eSPI(); AnimatedGIF gif; +WebServer web(80); +DNSServer dnsServer; -// Cara actual +// Cara enum Emotion { EMO_NEUTRAL = 1, EMO_HAPPY, EMO_WORRIED, EMO_SAD, EMO_CRY, EMO_SURPRISED, EMO_ANGRY, EMO_SLEEPY, EMO_WINK, EMO_LOVE, @@ -66,6 +75,13 @@ uint32_t lastSeenId = 0; Emotion currentEmotion = EMO_NEUTRAL; 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 int16_t gif_x = 0, gif_y = 0; 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 }; #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 -------------------- static void *gif_open_cb(const char *fname, int32_t *pSize) { 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 y = pDraw->iY + gif_y; int16_t w = pDraw->iWidth; - if (pDraw->iY == 0) { tft.startWrite(); tft.setAddrWindow(x, y, w, 1); } - tft.pushColors(pixels, w, false); - if (pDraw->iY == pDraw->iHeight - 1) { tft.endWrite(); } } -// -------------------- Vib & Tareas -------------------- +// -------------------- Vib -------------------- static void startVib() { vibActive = true; vibStart = millis(); @@ -160,7 +179,6 @@ static void emotionToGif(Emotion e, char *out, size_t sz) { out[sz - 1] = '\0'; } -// Mapeo emoción según prioridad static Emotion emotionForTask(const Task *t) { if (!t) return EMO_NEUTRAL; if (strcmp(t->priority, "high") == 0) return EMO_WORKING; @@ -200,7 +218,6 @@ static void drawTaskScreen() { tft.fillScreen(TFT_BLACK); drawHeader(); - // Banda de prioridad uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED : (strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW; tft.fillRect(0, 22, 240, 12, c); @@ -210,7 +227,6 @@ static void drawTaskScreen() { String prio = String("PRIORIDAD ") + currentTask.priority; tft.drawString(prio, 120, 24, 2); - // Título (debajo de la cara que está en la esquina) tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextSize(2); tft.setTextDatum(TL_DATUM); @@ -229,19 +245,30 @@ static void drawTaskScreen() { tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); int mins = currentTask.next_due_in / 60; String due = "Vence en "; - if (mins >= 60) { - due += String(mins / 60) + "h " + String(mins % 60) + "m"; - } else if (mins > 0) { - due += String(mins) + " min"; - } else { - due += "ahora"; - } + if (mins >= 60) due += String(mins / 60) + "h " + String(mins % 60) + "m"; + else if (mins > 0) due += String(mins) + " min"; + else due += "ahora"; tft.drawString(due, 8, 195, 2); } 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 -------------------- static void setEmotion(Emotion e) { if (e == currentEmotion && gifOpen) return; @@ -249,10 +276,7 @@ static void setEmotion(Emotion e) { emotionToGif(e, name, sizeof(name)); if (strcmp(name, currentGifName) == 0 && gifOpen) return; - if (gifOpen) { - gif.close(); - gifOpen = false; - } + if (gifOpen) { gif.close(); gifOpen = false; } strncpy(currentGifName, name, sizeof(currentGifName) - 1); char path[40]; @@ -264,15 +288,16 @@ static void setEmotion(Emotion e) { tft.fillRect(gif_x, gif_y, FACE_SIZE, FACE_SIZE, TFT_WHITE); if (gif.open(path, gif_open_cb, gif_close_cb, gif_read_cb, gif_seek_cb, gif_draw_callback)) { - Serial.printf("GIF abierto: %s\n", path); gifOpen = true; currentEmotion = e; nextGifMs = millis(); } else { - Serial.printf("GIF no se pudo abrir: %s\n", path); + drawEmotionFallback(e); + currentEmotion = e; } } 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) { if (WiFi.status() != WL_CONNECTED) return false; HTTPClient http; http.begin(url); http.setTimeout(API_TIMEOUT_MS); - if (strlen(DEVICE_TOKEN) > 0) { - http.addHeader("Authorization", "Bearer " DEVICE_TOKEN); + if (strlen(cfgToken) > 0) { + http.addHeader("Authorization", String("Bearer ") + cfgToken); } int code = http.GET(); if (code != 200) { http.end(); return false; } @@ -309,18 +334,19 @@ static bool httpPostJson(const char *url, const char *body) { http.begin(url); http.setTimeout(API_TIMEOUT_MS); http.addHeader("Content-Type", "application/json"); - if (strlen(DEVICE_TOKEN) > 0) { - http.addHeader("Authorization", "Bearer " DEVICE_TOKEN); + if (strlen(cfgToken) > 0) { + http.addHeader("Authorization", String("Bearer ") + cfgToken); } int code = http.POST(body); http.end(); return code >= 200 && code < 300; } -// -------------------- Polling / Acciones -------------------- +// -------------------- Lógica de tareas -------------------- static void pollTasks() { + if (WiFi.status() != WL_CONNECTED) return; 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; if (!httpGetJson(url, doc)) return; @@ -352,7 +378,7 @@ static void pollTasks() { static void actionDone() { if (!hasTask) return; 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), "{}"); if (httpPostJson(url, body)) { lastSeenId = 0; @@ -364,7 +390,7 @@ static void actionDone() { static void actionSnooze() { if (!hasTask) return; 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); if (httpPostJson(url, body)) { lastSeenId = 0; @@ -377,6 +403,7 @@ static void actionNext() { pollTasks(); } // -------------------- Botones -------------------- static void handleButtons() { + if (wifiState == WIFI_PORTAL) return; // no atender botones durante config uint32_t now = millis(); for (int i = 0; i < 3; i++) { 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"=====( + + + + +DummyBot - Configuración + + + +
+
+
🤖
+

DummyBot

+

Configura tu bot. Solo lo harás una vez.

+ +
+ 📡 Estás conectado al bot. Vuelve a conectarte a tu WiFi normal después de guardar. +
+ +
+ + +
Para distinguirlo si tienes varios
+ + + +
Solo redes 2.4 GHz (las 5 GHz no las soporta el ESP32)
+ + + + + + +
Donde está tu Laravel (ej: https://dummybot.example.com)
+ + + +
Lo copias en la web: Ajustes → Dispositivos → Ver token
+ + +
+ +
+
+ +
+
+
+ +)====="; + +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", "

Falta SSID, backend o token

"); + 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", "

SSID vacío

"); 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"=====( +DummyBot · Guardado +
+
+

Guardado

+

El bot se está reiniciando para conectarse a tu WiFi.

+

👉 Vuelve a la lista de WiFi y conectate a __SSID__

+

El bot aparecerá como conectado en la web cuando termine de arrancar (~15 s).

+
)====="; + 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 -------------------- void setup() { Serial.begin(115200); delay(200); - Serial.println("\n=== DummyBot v2 arrancando ==="); + Serial.println("\n=== DummyBot v3 arrancando ==="); pinMode(PIN_BTN_NEXT, INPUT); pinMode(PIN_BTN_DONE, INPUT); pinMode(PIN_BTN_SNOOZE, INPUT); pinMode(PIN_VIBRATOR, OUTPUT); digitalWrite(PIN_VIBRATOR, LOW); - pinMode(PIN_TFT_BL, OUTPUT); digitalWrite(PIN_TFT_BL, HIGH); @@ -409,7 +690,6 @@ void setup() { tft.setRotation(0); tft.fillScreen(TFT_BLACK); - // SPIFFS if (!SPIFFS.begin(true)) { Serial.println("ERROR: SPIFFS mount failed"); } else { @@ -419,64 +699,71 @@ void setup() { drawIdle(); - // Modo demo controlado por una constante de compile -#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 + // Pantalla de bienvenida mientras carga tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.setTextSize(2); - tft.drawString("WiFi: conecta", 120, 90, 2); + tft.drawString("DummyBot", 120, 90, 4); tft.setTextSize(1); - tft.drawString("AP: DummyBot-Setup", 120, 130, 2); - tft.drawString("pass: dummybot", 120, 145, 2); - drawFooter("192.168.4.1"); + tft.drawString("Iniciando...", 120, 130, 2); - WiFiManager wm; - wm.setConfigPortalTimeout(60); - wm.setAPCallback([](WiFiManager *) { - Serial.printf("AP abierto DummyBot-Setup, IP: %s\n", - WiFi.softAPIP().toString().c_str()); - }); - if (!wm.autoConnect(WIFI_AP_NAME, WIFI_AP_PASS)) { - Serial.println("WiFi falló -> seguimos sin red"); - setEmotion(EMO_WORRIED); + // ¿Hay config guardada? + bool haveCfg = loadConfig(); + if (haveCfg) { + Serial.printf("Config cargada: SSID='%s', Bot='%s'\n", cfgSsid, cfgName); + startWifiConnect(); + // Pantalla de "conectando..." + tft.fillScreen(TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(2); + tft.drawString("WiFi...", 120, 100, 2); + tft.setTextSize(1); + tft.drawString(cfgSsid, 120, 130, 2); } else { - Serial.printf("WiFi OK, IP: %s\n", WiFi.localIP().toString().c_str()); - setEmotion(EMO_HAPPY); - pollTasks(); + Serial.println("Sin config, abro portal..."); + wifiState = WIFI_PORTAL; + setupPortal(); } - lastPoll = millis(); -#endif } 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(); updateVib(); updateGif(); uint32_t now = millis(); - // Demo: rotar emociones cada 3 s si no hay tarea real - if (!hasTask) { - 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 }; - if (now - lastDemo > 3000) { - lastDemo = now; - demo_idx = (demo_idx + 1) % 8; - setEmotion(demo[demo_idx]); + if (wifiState == WIFI_CONNECTED) { + if (now - lastPoll >= POLL_MS) { + lastPoll = now; + pollTasks(); } } - if (now - lastPoll >= POLL_MS) { - lastPoll = now; - pollTasks(); - } + demoLoop(); delay(20); -} +} \ No newline at end of file