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:
parent
fd47a622cc
commit
6ddc1e20be
@ -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
|
||||
|
||||
447
src/main.cpp
447
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 <Arduino.h>
|
||||
@ -12,9 +13,10 @@
|
||||
#include <SPIFFS.h>
|
||||
#include <AnimatedGIF.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <WiFiManager.h>
|
||||
|
||||
// -------------------- 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"=====(<!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 --------------------
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user