fix: VCC de pantalla en 5V (no 3V3), RST en GPIO 15 (no GPIO 2); firmware con texto + portal (sin GIFs)

This commit is contained in:
javiservices 2026-06-17 10:24:49 +02:00
parent 6ddc1e20be
commit fe08f175e7
4 changed files with 246 additions and 502 deletions

View File

@ -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

View File

@ -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

View File

@ -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 = +<main.cpp> -<tests/>
; ─────────────────────────────────────────────────────────────
; 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 = +<tests/diag.cpp>
; ─────────────────────────────────────────────────────────────
; 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 = +<tests/spi_loopback.cpp>
build_src_filter = +<tests/diag.cpp>

View File

@ -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 <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <SPIFFS.h>
#include <AnimatedGIF.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
@ -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<JsonArray>();
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"=====(<!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>)=====";
// -------------------- 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"><title>DummyBot</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f5;min-height:100vh;padding:1rem;display:flex;align-items:center;justify-content:center}.c{max-width:480px;width:100%}.card{background:#18181b;border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:1.5rem;box-shadow:0 20px 60px rgba(0,0,0,0.5)}h1{margin:0 0 .25rem;font-size:1.4rem;background:linear-gradient(135deg,#818cf8,#c084fc);-webkit-background-clip:text;background-clip:text;color:transparent}p.lead{margin:0 0 1rem;color:#a1a1aa;font-size:.85rem}label{display:block;font-size:.7rem;color:#a1a1aa;margin:.8rem 0 .25rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em}input{width:100%;padding:.55rem .75rem;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,0.2)}button{margin-top:1rem;width:100%;padding:.65rem;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;border:none;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer}.help{font-size:.7rem;color:#71717a;margin-top:.2rem}.div{height:1px;background:rgba(255,255,255,0.08);margin:1rem 0}.emoji{font-size:2rem;margin-bottom:.3rem}</style></head><body><div class="c"><div class="card"><div class="emoji">🤖</div><h1>DummyBot</h1><p class="lead">Configura tu bot. Solo lo harás una vez.</p><form method="POST" action="/save"><label>Nombre del bot</label><input type="text" name="name" value="__NAME__" required maxlength="40"><label>Nombre de tu WiFi (SSID)</label><input type="text" name="ssid" value="__SSID__" required maxlength="32" autofocus><div class="help">Solo redes 2.4 GHz</div><label>Contraseña WiFi</label><input type="password" name="pass" value="__PASS__" maxlength="64"><label>URL del servidor</label><input type="url" name="backend" value="__BACKEND__" required><label>Token del dispositivo</label><input type="text" name="token" value="__TOKEN__" required maxlength="80"><div class="help">En la web: Ajustes → Dispositivos</div><button type="submit">💾 Guardar y conectar</button></form><div class="div"></div><form method="POST" action="/reset" onsubmit="return confirm('¿Borrar config?')"><button type="submit" style="background:transparent;border:1px solid rgba(239,68,68,0.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("__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", "<h1>Falta SSID, backend o token</h1>");
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", "<h1>SSID vacío</h1>"); 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"=====(<!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>)=====";
String ok = R"=====(<!DOCTYPE html><html><head><meta charset="UTF-8"><title>OK</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}.c{max-width:480px;background:#18181b;border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:2rem;text-align:center}h1{color:#86efac;font-size:1.8rem;margin:0 0 .5rem}.e{font-size:3.5rem;margin-bottom:1rem}p{color:#a1a1aa}</style></head><body><div class="c"><div class="e">✅</div><h1>Guardado</h1><p>El bot se está reiniciando.</p><p style="margin-top:1rem;color:#818cf8">👉 Conéctate a <strong>__SSID__</strong></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", "/");
@ -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);
}