From fe08f175e78f9df336c6e17cc42a62d82abf72d3 Mon Sep 17 00:00:00 2001 From: javiservices Date: Wed, 17 Jun 2026 10:24:49 +0200 Subject: [PATCH] fix: VCC de pantalla en 5V (no 3V3), RST en GPIO 15 (no GPIO 2); firmware con texto + portal (sin GIFs) --- README.md | 30 +- include/User_Setup.h | 2 +- platformio.ini | 38 +-- src/main.cpp | 678 ++++++++++++++----------------------------- 4 files changed, 246 insertions(+), 502 deletions(-) diff --git a/README.md b/README.md index e6cdbb8..c373786 100644 --- a/README.md +++ b/README.md @@ -14,30 +14,28 @@ Firmware del ESP32 para **DummyBot**, task manager para TDAH. ``` ESP32 (WROOM-32) M130T-240240-RGB-7-V1.1 ───────────────────── ───────────────────────── -3V3 ───────▶ VCC +5V (VIN) ───────▶ VCC ← IMPORTANTE: 5V, NO 3V3 GND ───────▶ GND GPIO 18 ───────▶ SCL (SPI clock) GPIO 23 ───────▶ SDA (SPI MOSI) -GPIO 2 ───────▶ RES (reset) +GPIO 15 ───────▶ RES (reset) ← IMPORTANTE: GPIO 15, NO GPIO 2 GPIO 4 ───────▶ DC (data/command) GPIO 32 ───────▶ BL (backlight) CS a GND (no se usa) - -ESP32 TTP223 (×3) -───────── ─────────── -GPIO 26 ──▶ SIG TTP223 #1 Siguiente tarea -GPIO 27 ──▶ SIG TTP223 #2 Marcar como hecha -GPIO 14 ──▶ SIG TTP223 #3 Snooze 5 min -3V3 ──▶ VCC de los 3 -GND ──▶ GND de los 3 - -ESP32 Módulo vibrador (con MOSFET) -───────── ────────────────────────────── -GPIO 25 ──▶ Gate del MOSFET -5V (VIN) ──▶ V+ del vibrador -GND ──▶ GND del vibrador ``` +#### Notas importantes sobre cableado + +1. **VCC de la pantalla en 5V (pin VIN)**, NO en 3V3. La pantalla necesita más corriente + de la que el regulador 3.3V del ESP32 puede suministrar bajo carga (WiFi + SPI). + Con 3V3 el panel se ilumina pero no muestra nada. + +2. **RES (reset) en GPIO 15**, NO en GPIO 2. GPIO 2 está compartido con el LED interno + azul del ESP32 DevKitC, lo que interfiere con la señal de reset. + +3. Los **3 botones TTP223** van a GPIO 26, 27 y 14, alimentados desde 3V3. +4. El **vibrador** va a GPIO 25 con un transistor (MOSFET AO3400 o similar). + Para el motor 1027 (3V, 70-100 mA), un MOSFET AO3400 o 2N7000 + resistencia 100Ω en gate. Si solo tienes NPN (BC547/2N2222), esquema alternativo: motor entre 3V3 y colector, resistencia 1kΩ en base. ## Build diff --git a/include/User_Setup.h b/include/User_Setup.h index 2b8739f..5cb1ad1 100644 --- a/include/User_Setup.h +++ b/include/User_Setup.h @@ -15,7 +15,7 @@ #define TFT_SCLK 18 #define TFT_CS -1 // CS no se usa (va a GND en el módulo) #define TFT_DC 4 // Data/Command -#define TFT_RST 2 // Reset +#define TFT_RST 15 // Reset (movido desde GPIO 2 por conflicto con LED interno) #define TFT_BL 32 // Backlight (LED) – PWM opcional #define TFT_SPI_FREQUENCY 40000000 diff --git a/platformio.ini b/platformio.ini index 5ffea4b..a564b78 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,5 @@ ; PlatformIO Project Configuration File -; DummyBot – task manager for ESP32 + ST7789 240x240 + 3x TTP223 + vibrador -; Firmware: TFT_eSPI (texto) + SPIFFS + AnimatedGIF (caritas kawaii) -; Backend: Laravel (HTTP polling cada 30s) +; DummyBot – task manager ESP32 [env:esp32dev] platform = espressif32 @ 6.5.0 @@ -11,27 +9,29 @@ monitor_speed = 115200 upload_speed = 921600 board_build.partitions = partitions.csv -; Libs +; Libs mínimas (sin GIFs ni WiFiManager) lib_deps = bodmer/TFT_eSPI@^2.5.43 bblanchon/ArduinoJson@^7.0.4 - bitbank2/AnimatedGIF@^1.4.1 -; User config del display (TFT_eSPI) y WiFi build_flags = -DCORE_DEBUG_LEVEL=3 -DUSER_SETUP_LOADED=1 -include $PROJECT_DIR/include/User_Setup.h - -DWM_DEBUG_LEVEL=3 + + ; ─── CONFIG POR DEFECTO ─── + ; Los defaults están en main.cpp (DEFAULT_WIFI_SSID, etc). + ; Si quieres cambiarlos sin tocar código, define aquí las macros: + ; -DDEFAULT_WIFI_SSID=\"otro\" -DDEFAULT_WIFI_PASS=\"otro\" + ; + ; Backend y token (sobreescribibles vía portal web) -DBACKEND_URL=\"http://157.180.77.232:18080\" - -DDEVICE_TOKEN=\"tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU\" - ; Para apuntar a tu servidor, sobreescribe BACKEND_URL en una env: - ; [env:mi-server] - ; build_flags = ${env:esp32dev.build_flags} -DBACKEND_URL=\"https://midominio.com\" + -DDEFAULT_DEVICE_TOKEN=\"tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU\" + build_src_filter = + - ; ───────────────────────────────────────────────────────────── -; Entorno de DIAGNÓSTICO: solo compila diag.cpp (sin main.cpp) +; Entorno de DIAGNÓSTICO: solo compila diag.cpp ; ───────────────────────────────────────────────────────────── [env:diag] platform = espressif32 @ 6.5.0 @@ -45,16 +45,4 @@ build_flags = -DCORE_DEBUG_LEVEL=3 -DUSER_SETUP_LOADED=1 -include $PROJECT_DIR/include/User_Setup.h -build_src_filter = + - -; ───────────────────────────────────────────────────────────── -; Test SPI loopback (para verificar el bus SPI del ESP32) -; ───────────────────────────────────────────────────────────── -[env:spiloop] -platform = espressif32 @ 6.5.0 -board = esp32dev -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 -lib_deps = -build_src_filter = + +build_src_filter = + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 28714cf..1ce0be6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,17 +1,18 @@ // ============================================================ -// DummyBot – firmware ESP32 v3 -// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240 SPI) +// DummyBot – firmware ESP32 v4 (TEXTO + Portal) +// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240) +// - ** IMPORTANTE: VCC de la pantalla en 5V (VIN), no 3V3 ** +// - ** IMPORTANTE: RST de la pantalla en GPIO 15, no GPIO 2 ** // - 3x TTP223 (Siguiente / Hecho / Snooze) -// - 1x vibrador (GPIO 25 con transistor) -// - 12 GIFs animados en SPIFFS -// - ** Captive portal ** para configurar WiFi + token desde el móvil +// - 1x vibrador (GPIO 25) +// - WiFi (con reconexión) + HTTP polling al backend Laravel +// - Captive portal SIN GIFs (texto puro) // ============================================================ #include #include #include #include -#include #include #include #include @@ -31,114 +32,57 @@ #define VIB_PATTERN_MS 1500 #define SNOOZE_MINUTES 5 #define API_TIMEOUT_MS 4000 -#define FACE_SIZE 80 const char* WIFI_AP_NAME = "DummyBot-Setup"; -const char* WIFI_AP_PASS = "dummybot"; // >=8 chars (requisito AP) +const char* WIFI_AP_PASS = "dummybot"; + +#ifndef DEFAULT_WIFI_SSID +#define DEFAULT_WIFI_SSID "BatKuevaUp" +#endif +#ifndef DEFAULT_WIFI_PASS +#define DEFAULT_WIFI_PASS "JaMon2026" +#endif #ifndef BACKEND_URL #define BACKEND_URL "http://157.180.77.232:18080" #endif #ifndef DEFAULT_DEVICE_TOKEN -#define DEFAULT_DEVICE_TOKEN "" +#define DEFAULT_DEVICE_TOKEN "tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU" #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; +TFT_eSPI tft = TFT_eSPI(); +WebServer web(80); +DNSServer dnsServer; -// Cara -enum Emotion { - EMO_NEUTRAL = 1, EMO_HAPPY, EMO_WORRIED, EMO_SAD, EMO_CRY, - EMO_SURPRISED, EMO_ANGRY, EMO_SLEEPY, EMO_WINK, EMO_LOVE, - EMO_WORKING, EMO_EXCITED -}; struct Task { - int id; - char title[64]; - char priority[8]; - int next_due_in; + int id; + char title[64]; + char priority[8]; + int next_due_in; }; + Task currentTask; bool hasTask = false; uint32_t lastPoll = 0; 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; -int lastGifDelay = 0; -uint32_t nextGifMs = 0; - -// Vibrador bool vibActive = false; uint32_t vibStart = 0; -// Botones 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")); - if (!*f) { delete f; return nullptr; } - *pSize = f->size(); - return f; -} -static void gif_close_cb(void *handle) { - if (!handle) return; - fs::File *f = (fs::File *)handle; - f->close(); - delete f; -} -static int32_t gif_read_cb(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) { - fs::File *f = (fs::File *)pFile->fHandle; - return f->read(pBuf, iLen); -} -static int32_t gif_seek_cb(GIFFILE *pFile, int32_t iPos) { - fs::File *f = (fs::File *)pFile->fHandle; - return f->seek(iPos) ? 1 : 0; -} - -static void gif_draw_callback(GIFDRAW *pDraw) { - uint16_t *pixels = (uint16_t *)pDraw->pPixels; - 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(); - } -} +// Config persistente +#define WIFI_CFG_PATH "/wifi.cfg" +char cfgSsid[64] = ""; +char cfgPass[64] = ""; +char cfgToken[80] = ""; +char cfgBackend[128] = ""; +char cfgName[40] = "Mi DummyBot"; +bool portalActive = false; // -------------------- Vib -------------------- static void startVib() { @@ -159,41 +103,13 @@ static void updateVib() { } // -------------------- UI -------------------- -static void emotionToGif(Emotion e, char *out, size_t sz) { - const char *name = "01_neutral.gif"; - switch (e) { - case EMO_NEUTRAL: name = "01_neutral.gif"; break; - case EMO_HAPPY: name = "02_happy.gif"; break; - case EMO_WORRIED: name = "03_worried.gif"; break; - case EMO_SAD: name = "04_sad.gif"; break; - case EMO_CRY: name = "05_cry.gif"; break; - case EMO_SURPRISED: name = "06_surprised.gif"; break; - case EMO_ANGRY: name = "07_angry.gif"; break; - case EMO_SLEEPY: name = "08_sleepy.gif"; break; - case EMO_WINK: name = "09_wink.gif"; break; - case EMO_LOVE: name = "10_love.gif"; break; - case EMO_WORKING: name = "11_working.gif"; break; - case EMO_EXCITED: name = "12_excited.gif"; break; - } - strncpy(out, name, sz - 1); - out[sz - 1] = '\0'; -} - -static Emotion emotionForTask(const Task *t) { - if (!t) return EMO_NEUTRAL; - if (strcmp(t->priority, "high") == 0) return EMO_WORKING; - if (strcmp(t->priority, "low") == 0) return EMO_HAPPY; - return EMO_NEUTRAL; -} - -static void drawHeader() { +static void drawHeader(const char *title) { tft.fillRect(0, 0, 240, 22, TFT_NAVY); tft.setTextColor(TFT_WHITE, TFT_NAVY); tft.setTextSize(1); tft.setTextDatum(TL_DATUM); - tft.drawString("DummyBot - tareas", 6, 6, 2); + tft.drawString(title, 6, 6, 2); } - static void drawFooter(const char *line) { tft.fillRect(0, 218, 240, 22, TFT_DARKGREY); tft.setTextColor(TFT_WHITE, TFT_DARKGREY); @@ -201,37 +117,58 @@ static void drawFooter(const char *line) { tft.setTextDatum(BC_DATUM); tft.drawString(line, 120, 230, 1); } +static void drawWifiIcon(int16_t x, int16_t y, bool connected) { + uint16_t color = connected ? TFT_GREEN : TFT_RED; + tft.fillCircle(x, y, 4, color); +} -static void drawIdle() { +static void drawIdleScreen() { tft.fillScreen(TFT_BLACK); - drawHeader(); + drawHeader("DummyBot"); + drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); + tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.setTextSize(2); tft.drawString("Sin tareas :)", 120, 100, 4); tft.setTextSize(1); tft.drawString("Anade desde la web", 120, 140, 2); + if (WiFi.status() != WL_CONNECTED) { + tft.setTextColor(TFT_YELLOW, TFT_BLACK); + tft.drawString("WiFi: sin conexion", 120, 170, 2); + } else { + tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + tft.drawString(WiFi.localIP().toString().c_str(), 120, 170, 2); + } drawFooter("Snooze | Hecha | Siguiente"); } static void drawTaskScreen() { tft.fillScreen(TFT_BLACK); - drawHeader(); + drawHeader("DummyBot - tarea"); + drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); 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); + tft.fillRect(0, 22, 240, 14, c); tft.setTextColor(TFT_BLACK, c); tft.setTextDatum(MC_DATUM); tft.setTextSize(1); String prio = String("PRIORIDAD ") + currentTask.priority; tft.drawString(prio, 120, 24, 2); + const char* emoji = "(._.)"; + if (strcmp(currentTask.priority, "high") == 0) emoji = "/(>_<)\\"; + else if (strcmp(currentTask.priority, "low") == 0) emoji = "^_^"; tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(4); + tft.drawString(emoji, 120, 80, 4); + tft.setTextSize(2); tft.setTextDatum(TL_DATUM); String title = String(currentTask.title); - int y = 110; + int y = 130; int maxChars = 16; int idx = 0; while (idx < (int)title.length() && y < 200) { @@ -240,90 +177,35 @@ static void drawTaskScreen() { y += 28; } - if (currentTask.next_due_in > 0) { - tft.setTextSize(1); - 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"; - 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); +static void drawWifiScreen(const char *msg) { + tft.fillScreen(TFT_BLACK); + drawHeader("DummyBot - WiFi"); + tft.setTextColor(TFT_WHITE, TFT_BLACK); 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(2); + tft.drawString(msg, 120, 80, 2); tft.setTextSize(1); - tft.drawString(currentGifName, gif_x + FACE_SIZE / 2, gif_y + FACE_SIZE / 2 + 18, 1); + tft.drawString(WIFI_AP_NAME, 120, 115, 2); + tft.drawString(WIFI_AP_PASS, 120, 135, 2); + drawFooter("conecta el movil para configurar"); } -// -------------------- GIF control -------------------- -static void setEmotion(Emotion e) { - if (e == currentEmotion && gifOpen) return; - char name[32]; - emotionToGif(e, name, sizeof(name)); - if (strcmp(name, currentGifName) == 0 && gifOpen) return; - - if (gifOpen) { gif.close(); gifOpen = false; } - strncpy(currentGifName, name, sizeof(currentGifName) - 1); - - char path[40]; - snprintf(path, sizeof(path), "/gifs/%s", currentGifName); - gif_x = 240 - FACE_SIZE - 4; - gif_y = 28; - - if (SPIFFS.exists(path)) { - 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)) { - gifOpen = true; - currentEmotion = e; - nextGifMs = millis(); - } else { - drawEmotionFallback(e); - currentEmotion = e; - } - } else { - drawEmotionFallback(e); - currentEmotion = e; - } -} - -static void updateGif() { - if (!gifOpen) return; - if ((int32_t)(millis() - nextGifMs) >= 0) { - int delay_ms = 0; - if (gif.playFrame(false, &delay_ms) == -1) { - gif.reset(); - return; - } - nextGifMs = millis() + (delay_ms > 0 ? delay_ms : 100); - } -} - -// -------------------- HTTP cliente -------------------- +// -------------------- HTTP -------------------- 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(cfgToken) > 0) { - http.addHeader("Authorization", String("Bearer ") + cfgToken); - } + http.addHeader("Authorization", String("Bearer ") + String(DEFAULT_DEVICE_TOKEN)); int code = http.GET(); - if (code != 200) { http.end(); return false; } + if (code != 200) { + Serial.printf("HTTP %d en %s\n", code, url); + http.end(); + return false; + } DeserializationError err = deserializeJson(doc, http.getStream()); http.end(); return !err; @@ -334,27 +216,28 @@ 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(cfgToken) > 0) { - http.addHeader("Authorization", String("Bearer ") + cfgToken); - } + http.addHeader("Authorization", String("Bearer ") + String(DEFAULT_DEVICE_TOKEN)); int code = http.POST(body); http.end(); return code >= 200 && code < 300; } -// -------------------- Lógica de tareas -------------------- +// -------------------- Lógica -------------------- static void pollTasks() { if (WiFi.status() != WL_CONNECTED) return; char url[160]; - snprintf(url, sizeof(url), "%s/api/device/tasks/pending", cfgBackend); + snprintf(url, sizeof(url), "%s/api/device/tasks/pending", BACKEND_URL); + Serial.printf("GET %s\n", url); JsonDocument doc; - if (!httpGetJson(url, doc)) return; + if (!httpGetJson(url, doc)) { + drawIdleScreen(); + return; + } JsonArray arr = doc.as(); if (arr.size() == 0) { hasTask = false; - if (currentEmotion != EMO_SLEEPY) setEmotion(EMO_SLEEPY); - drawIdle(); + drawIdleScreen(); return; } JsonObject first = arr[0]; @@ -368,42 +251,33 @@ static void pollTasks() { if (lastSeenId != 0) startVib(); lastSeenId = (uint32_t)t.id; } - currentTask = t; hasTask = true; drawTaskScreen(); - setEmotion(emotionForTask(&t)); } static void actionDone() { if (!hasTask) return; - char url[160], body[8]; - snprintf(url, sizeof(url), "%s/api/device/tasks/%d/done", cfgBackend, currentTask.id); - snprintf(body, sizeof(body), "{}"); - if (httpPostJson(url, body)) { + char url[160]; + snprintf(url, sizeof(url), "%s/api/device/tasks/%d/done", BACKEND_URL, currentTask.id); + if (httpPostJson(url, "{}")) { lastSeenId = 0; - setEmotion(EMO_WINK); - delay(800); pollTasks(); } } static void actionSnooze() { if (!hasTask) return; char url[160], body[40]; - snprintf(url, sizeof(url), "%s/api/device/tasks/%d/snooze", cfgBackend, currentTask.id); + snprintf(url, sizeof(url), "%s/api/device/tasks/%d/snooze", BACKEND_URL, currentTask.id); snprintf(body, sizeof(body), "{\"minutes\":%d}", SNOOZE_MINUTES); if (httpPostJson(url, body)) { lastSeenId = 0; - setEmotion(EMO_WORRIED); - delay(800); pollTasks(); } } 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)) { @@ -417,151 +291,71 @@ static void handleButtons() { } } -// -------------------- Persistencia de config -------------------- -static bool loadConfig() { - if (!SPIFFS.exists(WIFI_CFG_PATH)) return false; +// -------------------- Config persistente -------------------- +static void loadConfig() { + if (!SPIFFS.exists(WIFI_CFG_PATH)) return; fs::File f = SPIFFS.open(WIFI_CFG_PATH, "r"); - if (!f) return false; + if (!f) return; JsonDocument doc; - DeserializationError err = deserializeJson(doc, f); + if (deserializeJson(doc, f) == DeserializationError::Ok) { + 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"] | "Mi DummyBot", sizeof(cfgName) - 1); + Serial.printf("Config cargada de SPIFFS: SSID='%s'\n", cfgSsid); + } 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() { + fs::File f = SPIFFS.open(WIFI_CFG_PATH, "w"); + if (!f) return; 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); + cfgSsid[0] = cfgPass[0] = cfgToken[0] = cfgBackend[0] = 0; } -// -------------------- 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
- - -
- -
-
- -
-
-
- -)====="; +// -------------------- Portal -------------------- +static const char PORTAL_HTML[] PROGMEM = R"=====(DummyBot
🤖

DummyBot

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

Solo redes 2.4 GHz
En la web: Ajustes → Dispositivos
)====="; static String renderPortal() { String html = FPSTR(PORTAL_HTML); html.replace("__NAME__", String(cfgName)); - html.replace("__BACKEND__", String(cfgBackend)); + html.replace("__SSID__", String(cfgSsid)); + html.replace("__PASS__", String(cfgPass)); + html.replace("__BACKEND__", String(cfgBackend[0] ? cfgBackend : BACKEND_URL)); 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 handleCaptive() { + web.sendHeader("Location", "http://192.168.4.1/"); + web.send(302, "text/plain", ""); +} 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"); + if (!web.hasArg("ssid")) { web.send(400, "text/html", "Falta SSID"); return; } + String s = web.arg("ssid"); s.trim(); + if (!s.length()) { web.send(400, "text/html", "SSID vacio"); return; } 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; } + String n = web.hasArg("name") ? web.arg("name") : "Mi DummyBot"; + p.trim(); t.trim(); b.trim(); n.trim(); + if (!b.length()) b = BACKEND_URL; + if (!t.length()) t = DEFAULT_DEVICE_TOKEN; strncpy(cfgSsid, s.c_str(), sizeof(cfgSsid) - 1); strncpy(cfgPass, p.c_str(), sizeof(cfgPass) - 1); @@ -570,29 +364,12 @@ static void handleSave() { 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).

-
)====="; + String ok = R"=====(OK

Guardado

El bot se está reiniciando.

👉 Conéctate a __SSID__

)====="; 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", "/"); @@ -601,82 +378,42 @@ static void handleReset() { ESP.restart(); } -static void handleNotFound() { - // Captive portal: cualquier URL redirige al root - handleCaptive(); -} - -static void setupPortal() { +static void startPortal() { 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) + Serial.printf("AP abierto: %s\n", WIFI_AP_NAME); 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.on("/", HTTP_GET, handleRoot); + web.on("/save", HTTP_POST, handleSave); + web.on("/reset", HTTP_POST, handleReset); + web.onNotFound(handleCaptive); web.begin(); - Serial.println("Servidor web del portal en http://192.168.4.1/"); + portalActive = true; +} +static void stopPortal() { + if (!portalActive) return; + web.stop(); + dnsServer.stop(); + WiFi.softAPdisconnect(true); + portalActive = false; } -// -------------------- WiFi conectar -------------------- -static void startWifiConnect() { - wifiState = WIFI_CONNECTING; - wifiConnectStart = millis(); - Serial.printf("Conectando a WiFi '%s'...\n", cfgSsid); +// -------------------- WiFi -------------------- +static void startSTA() { + const char *ssid = cfgSsid[0] ? cfgSsid : DEFAULT_WIFI_SSID; + const char *pass = cfgPass[0] ? cfgPass : DEFAULT_WIFI_PASS; + Serial.printf("Conectando a WiFi '%s'...\n", ssid); 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]); - } + WiFi.begin(ssid, pass); + strncpy(cfgSsid, ssid, sizeof(cfgSsid) - 1); + strncpy(cfgPass, pass, sizeof(cfgPass) - 1); } // -------------------- Setup / Loop -------------------- void setup() { Serial.begin(115200); - delay(200); - Serial.println("\n=== DummyBot v3 arrancando ==="); + delay(500); + Serial.println("\n=== DummyBot v4 ==="); pinMode(PIN_BTN_NEXT, INPUT); pinMode(PIN_BTN_DONE, INPUT); @@ -686,84 +423,105 @@ void setup() { pinMode(PIN_TFT_BL, OUTPUT); digitalWrite(PIN_TFT_BL, HIGH); + Serial.println("Init TFT..."); tft.init(); tft.setRotation(0); - tft.fillScreen(TFT_BLACK); + Serial.println("TFT OK"); - if (!SPIFFS.begin(true)) { - Serial.println("ERROR: SPIFFS mount failed"); - } else { - Serial.printf("SPIFFS OK, %u bytes usados / %u totales\n", - SPIFFS.usedBytes(), SPIFFS.totalBytes()); + if (!SPIFFS.begin(true)) Serial.println("SPIFFS ERROR"); + loadConfig(); + if (!cfgBackend[0]) strncpy(cfgBackend, BACKEND_URL, sizeof(cfgBackend) - 1); + if (!cfgToken[0]) strncpy(cfgToken, DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1); + + // Reset con BOOT 5s + pinMode(0, INPUT_PULLUP); + if (digitalRead(0) == LOW) { + uint32_t bootStart = millis(); + while (digitalRead(0) == LOW && millis() - bootStart < 5000) delay(50); + if (millis() - bootStart >= 5000) { + resetConfig(); + delay(1000); + } } - drawIdle(); - - // Pantalla de bienvenida mientras carga + // Pantalla de bienvenida tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(MC_DATUM); - tft.setTextSize(2); - tft.drawString("DummyBot", 120, 90, 4); + tft.setTextSize(3); + tft.drawString("DummyBot", 120, 100, 4); tft.setTextSize(1); - tft.drawString("Iniciando...", 120, 130, 2); + tft.drawString("Iniciando...", 120, 140, 2); - // ¿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.println("Sin config, abro portal..."); - wifiState = WIFI_PORTAL; - setupPortal(); - } + startSTA(); + Serial.println("Setup OK"); } +enum RunMode { MODE_CONNECTING, MODE_PORTAL, MODE_RUNNING }; +RunMode mode = MODE_CONNECTING; +uint32_t modeStart = 0; + void loop() { - // Servidor web (portal) si estamos en modo AP - if (wifiState == WIFI_PORTAL) { + updateVib(); + + if (mode == MODE_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"); + handleButtons(); + static uint32_t lastScreen = 0; + if (millis() - lastScreen > 2000) { + lastScreen = millis(); + int n = WiFi.softAPgetStationNum(); + char msg[40]; + snprintf(msg, sizeof(msg), "Config WiFi (%d)", n); + drawWifiScreen(msg); } return; } - updateWifi(); handleButtons(); - updateVib(); - updateGif(); uint32_t now = millis(); - if (wifiState == WIFI_CONNECTED) { - if (now - lastPoll >= POLL_MS) { + if (mode == MODE_CONNECTING) { + if (WiFi.status() == WL_CONNECTED) { + mode = MODE_RUNNING; + modeStart = now; + Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str()); + drawIdleScreen(); lastPoll = now; pollTasks(); + } else if (now - modeStart > 25000) { + Serial.println("WiFi falló, abriendo portal..."); + startPortal(); + mode = MODE_PORTAL; + } else if (now - modeStart > 1000) { + static int dots = 0; + dots = (dots + 1) % 4; + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(2); + char msg[20]; + snprintf(msg, sizeof(msg), "WiFi %.*s", dots, "..."); + tft.drawString(msg, 120, 100, 2); + tft.setTextSize(1); + tft.drawString(cfgSsid, 120, 140, 2); } + return; } - demoLoop(); - delay(20); + if (WiFi.status() != WL_CONNECTED) { + Serial.println("WiFi perdido, reintentando..."); + mode = MODE_CONNECTING; + modeStart = now; + startSTA(); + return; + } + + if (now - lastPoll >= POLL_MS) { + lastPoll = now; + pollTasks(); + } + delay(50); } \ No newline at end of file