- Botones en GPIO 25 (PLAY=DONE), 26 (V+=NEXT), 27 (V-=SNOOZE) - KCOM del módulo a GND, lógica invertida (HIGH reposo, LOW al pulsar) - INPUT_PULLUP, detección por flanco descendente con debounce 200ms - GPIO 25 ya no es vibrador (ahora es botón DONE), vibrador desactivado - OLED con feedback toast al pulsar (1.2s sin pisar) - Toast 'Nueva!' al recibir tarea nueva del polling - Quitado código de test ADC y de matriz resistiva Fixes pantalla: - GPIO 14 ya no es botón (era strapping pin, causaba glitches y reset) - Botones solo en 25/26/27 que no son strapping pins
706 lines
26 KiB
C++
706 lines
26 KiB
C++
// =============================================================
|
|
// DummyBot — firmware ESP32 v6.2 (botones invertidos + sin vibrador)
|
|
// OLED SSD1306 128x64 + WiFi STA + polling backend
|
|
// Botones: GPIO 25 = PLAY (DONE), 26 = V+ (NEXT), 27 = V- (SNOOZE)
|
|
// Lógica: INPUT_PULLUP → HIGH reposo, LOW al pulsar
|
|
// KCOM del módulo de botones → GND
|
|
// GPIO 25 (era vibrador) ahora es un botón → vibrador desactivado
|
|
// =============================================================
|
|
|
|
#include <Arduino.h>
|
|
#include <Wire.h>
|
|
#include <Adafruit_GFX.h>
|
|
#include <Adafruit_SSD1306.h>
|
|
#include <WiFi.h>
|
|
#include <WebServer.h>
|
|
#include <DNSServer.h>
|
|
#include <HTTPClient.h>
|
|
#include <ArduinoJson.h>
|
|
#include <Preferences.h>
|
|
|
|
// ============== HARDWARE ==============
|
|
static constexpr uint8_t PIN_BTN_PLAY = 25; // PLAY (DONE: marcar como hecha)
|
|
static constexpr uint8_t PIN_BTN_NEXT = 26; // V+ (NEXT: siguiente tarea)
|
|
static constexpr uint8_t PIN_BTN_SNOO = 27; // V- (SNOOZE: posponer 5 min)
|
|
static constexpr uint8_t PIN_BOOT = 0;
|
|
static constexpr int8_t OLED_SDA = 21;
|
|
static constexpr int8_t OLED_SCL = 22;
|
|
static constexpr uint8_t OLED_ADDR = 0x3C;
|
|
static constexpr int8_t OLED_RST = -1;
|
|
static constexpr uint8_t SCREEN_W = 128;
|
|
static constexpr uint8_t SCREEN_H = 64;
|
|
|
|
// ============== CONFIG COMPILETIME ==============
|
|
#ifndef DEFAULT_WIFI_SSID
|
|
#define DEFAULT_WIFI_SSID "PHARMACO"
|
|
#endif
|
|
#ifndef DEFAULT_WIFI_PASS
|
|
#define DEFAULT_WIFI_PASS "arnoldgym2010"
|
|
#endif
|
|
#ifndef BACKEND_URL
|
|
#define BACKEND_URL "http://157.180.77.232:18080"
|
|
#endif
|
|
#ifndef DEFAULT_DEVICE_TOKEN
|
|
#define DEFAULT_DEVICE_TOKEN "UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c"
|
|
#endif
|
|
|
|
static constexpr const char* AP_NAME = "DummyBot-Setup";
|
|
static constexpr const char* AP_PASS = "dummybot";
|
|
static constexpr const char* NVS_NS = "dummybot";
|
|
|
|
static constexpr uint32_t POLL_MS = 30000;
|
|
static constexpr uint32_t API_TIMEOUT_MS = 5000;
|
|
static constexpr uint32_t WIFI_TIMEOUT_MS = 20000;
|
|
static constexpr uint32_t DEBOUNCE_MS = 200;
|
|
static constexpr uint8_t SNOOZE_MINUTES = 5;
|
|
|
|
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, OLED_RST);
|
|
Preferences nvs;
|
|
WebServer webServer(80);
|
|
DNSServer dnsServer;
|
|
|
|
// =============================================================
|
|
// CONFIG
|
|
// =============================================================
|
|
struct Config {
|
|
String ssid, pass, token, backend, name;
|
|
|
|
void load() {
|
|
ssid = DEFAULT_WIFI_SSID;
|
|
pass = DEFAULT_WIFI_PASS;
|
|
token = DEFAULT_DEVICE_TOKEN;
|
|
backend = BACKEND_URL;
|
|
name = "Mi DummyBot";
|
|
nvs.begin(NVS_NS, true);
|
|
if (nvs.isKey("ssid")) ssid = nvs.getString("ssid", ssid);
|
|
if (nvs.isKey("pass")) pass = nvs.getString("pass", pass);
|
|
if (nvs.isKey("token")) token = nvs.getString("token", token);
|
|
if (nvs.isKey("backend")) backend = nvs.getString("backend", backend);
|
|
if (nvs.isKey("name")) name = nvs.getString("name", name);
|
|
nvs.end();
|
|
Serial.printf("[CFG] ssid='%s' backend='%s' token.len=%u\n",
|
|
ssid.c_str(), backend.c_str(), token.length());
|
|
}
|
|
|
|
void save() {
|
|
nvs.begin(NVS_NS, false);
|
|
nvs.putString("ssid", ssid);
|
|
nvs.putString("pass", pass);
|
|
nvs.putString("token", token);
|
|
nvs.putString("backend", backend);
|
|
nvs.putString("name", name);
|
|
nvs.end();
|
|
}
|
|
|
|
void clear() {
|
|
nvs.begin(NVS_NS, false);
|
|
nvs.clear();
|
|
nvs.end();
|
|
ssid = DEFAULT_WIFI_SSID;
|
|
pass = DEFAULT_WIFI_PASS;
|
|
token = DEFAULT_DEVICE_TOKEN;
|
|
backend = BACKEND_URL;
|
|
name = "Mi DummyBot";
|
|
}
|
|
} cfg;
|
|
|
|
// =============================================================
|
|
// UI
|
|
// =============================================================
|
|
namespace UI {
|
|
void showBoot() {
|
|
display.clearDisplay();
|
|
display.setTextSize(2);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(20, 16);
|
|
display.print(F("DummyBot"));
|
|
display.setTextSize(1);
|
|
display.setCursor(28, 50);
|
|
display.print(F("Iniciando..."));
|
|
display.display();
|
|
}
|
|
|
|
void showConnecting(const String& ssid, uint8_t dots) {
|
|
display.clearDisplay();
|
|
display.setTextSize(2);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(8, 8);
|
|
display.print(F("WiFi"));
|
|
for (uint8_t i = 0; i < dots; i++) display.print('.');
|
|
|
|
display.setTextSize(1);
|
|
display.setCursor(0, 36);
|
|
display.print(ssid.substring(0, 21));
|
|
display.setCursor(0, 48);
|
|
display.printf("IP: %s", WiFi.localIP().toString().c_str());
|
|
display.setCursor(0, 56);
|
|
display.print(WiFi.status() == WL_CONNECTED ? F("conectado") : F("esperando..."));
|
|
display.display();
|
|
}
|
|
|
|
void showIdle() {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.print(F("DummyBot [W]"));
|
|
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
|
|
display.setTextSize(2);
|
|
display.setCursor(8, 22);
|
|
display.print(F("Sin tareas"));
|
|
display.setTextSize(1);
|
|
display.setCursor(14, 50);
|
|
display.print(F("Anade desde la web"));
|
|
display.display();
|
|
}
|
|
|
|
void showTask(const String& title, const String& priority, int32_t dueSec) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.printf("DummyBot [%s]", priority.c_str());
|
|
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
|
|
display.setTextSize(2);
|
|
String t = title;
|
|
String l1 = t.substring(0, 10);
|
|
String l2 = t.length() > 10 ? t.substring(10, 20) : "";
|
|
display.setCursor(0, 16);
|
|
display.print(l1);
|
|
if (l2.length()) {
|
|
display.setCursor(0, 36);
|
|
display.print(l2);
|
|
}
|
|
display.setTextSize(1);
|
|
display.setCursor(0, 56);
|
|
if (dueSec > 60) display.printf("Vence: %d min", dueSec / 60);
|
|
else if (dueSec > 0) display.printf("Vence: %d s", dueSec);
|
|
else display.print(F("Refresca desde web"));
|
|
display.display();
|
|
}
|
|
|
|
void showToast(const char* msg, bool ok) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.print(F("DummyBot"));
|
|
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
|
|
display.setTextSize(2);
|
|
display.setCursor(8, 22);
|
|
display.print(msg);
|
|
display.setTextSize(1);
|
|
display.setCursor(0, 56);
|
|
display.print(ok ? F("[OK]") : F("[ERROR]"));
|
|
display.display();
|
|
}
|
|
|
|
void showPortal(uint8_t clients) {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.println(F("DummyBot - Setup"));
|
|
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
|
|
display.setCursor(0, 16);
|
|
display.printf("AP: %s", AP_NAME);
|
|
display.setCursor(0, 26);
|
|
display.printf("Pass: %s", AP_PASS);
|
|
display.setCursor(0, 38);
|
|
display.printf("Clientes: %u", clients);
|
|
display.setCursor(0, 50);
|
|
display.print(F("Abre 192.168.4.1"));
|
|
display.display();
|
|
}
|
|
} // namespace UI
|
|
|
|
// =============================================================
|
|
// TAREAS
|
|
// =============================================================
|
|
struct Task {
|
|
int32_t id = 0;
|
|
String title;
|
|
String priority = "med";
|
|
int32_t nextDueSec = 0;
|
|
};
|
|
|
|
static Task currentTask;
|
|
static bool hasTask = false;
|
|
static int32_t lastSeenId = 0;
|
|
static uint32_t toastUntil = 0;
|
|
static const char* toastMsg = nullptr;
|
|
static bool toastOk = true;
|
|
|
|
// =============================================================
|
|
// API
|
|
// =============================================================
|
|
namespace Api {
|
|
bool httpJson(const char* method, const char* path, const char* body, JsonDocument* out) {
|
|
if (WiFi.status() != WL_CONNECTED) return false;
|
|
if (cfg.token.isEmpty()) {
|
|
Serial.println("[API] sin token, skip");
|
|
return false;
|
|
}
|
|
String url = cfg.backend + path;
|
|
HTTPClient http;
|
|
http.begin(url);
|
|
http.setTimeout(API_TIMEOUT_MS);
|
|
http.addHeader("Authorization", "Bearer " + cfg.token);
|
|
if (body) http.addHeader("Content-Type", "application/json");
|
|
int code = (String(method) == "GET") ? http.GET() : http.POST(body ? body : "");
|
|
bool ok = (code >= 200 && code < 300);
|
|
Serial.printf("[API] %s %s -> %d\n", method, url.c_str(), code);
|
|
if (ok && out) {
|
|
String payload = http.getString();
|
|
DeserializationError err = deserializeJson(*out, payload);
|
|
if (err) { Serial.printf("[API] json err: %s\n", err.c_str()); ok = false; }
|
|
}
|
|
http.end();
|
|
return ok;
|
|
}
|
|
|
|
bool pollPending() {
|
|
JsonDocument doc;
|
|
if (!httpJson("GET", "/api/device/tasks/pending", nullptr, &doc)) {
|
|
hasTask = false;
|
|
return false;
|
|
}
|
|
JsonArray arr = doc.as<JsonArray>();
|
|
if (arr.size() == 0) { hasTask = false; return true; }
|
|
JsonObject t0 = arr[0];
|
|
currentTask.id = t0["id"] | 0;
|
|
currentTask.title = t0["title"] | "";
|
|
currentTask.priority = t0["priority"] | "med";
|
|
currentTask.nextDueSec = t0["next_due_in"] | 0;
|
|
hasTask = true;
|
|
if ((int32_t)currentTask.id != lastSeenId && lastSeenId != 0) {
|
|
// Nueva tarea detectada — pequeño feedback visual
|
|
UI::showToast("Nueva!", true);
|
|
toastUntil = millis() + 800;
|
|
}
|
|
lastSeenId = currentTask.id;
|
|
return true;
|
|
}
|
|
|
|
bool markDone() {
|
|
if (!hasTask) return false;
|
|
char path[64];
|
|
snprintf(path, sizeof(path), "/api/device/tasks/%d/done", currentTask.id);
|
|
bool ok = httpJson("POST", path, "{}", nullptr);
|
|
if (ok) { lastSeenId = 0; pollPending(); }
|
|
return ok;
|
|
}
|
|
|
|
bool snooze() {
|
|
if (!hasTask) return false;
|
|
char path[64];
|
|
snprintf(path, sizeof(path), "/api/device/tasks/%d/snooze", currentTask.id);
|
|
char body[32];
|
|
snprintf(body, sizeof(body), "{\"minutes\":%u}", SNOOZE_MINUTES);
|
|
bool ok = httpJson("POST", path, body, nullptr);
|
|
if (ok) { lastSeenId = 0; pollPending(); }
|
|
return ok;
|
|
}
|
|
} // namespace Api
|
|
|
|
// =============================================================
|
|
// INPUT — botones con INPUT_PULLUP, lógica invertida
|
|
// HIGH en reposo, LOW al pulsar, KCOM del módulo a GND
|
|
// =============================================================
|
|
namespace Input {
|
|
uint32_t lastMs[3] = {0, 0, 0};
|
|
bool lastState[3] = {true, true, true}; // pull-up: idle=HIGH
|
|
constexpr uint8_t PINS[3] = { PIN_BTN_NEXT, PIN_BTN_PLAY, PIN_BTN_SNOO };
|
|
|
|
// Acción común
|
|
void press(uint8_t i) {
|
|
switch (i) {
|
|
case 0:
|
|
Serial.println("[BTN] NEXT (V+)");
|
|
Api::pollPending();
|
|
UI::showToast("Siguiente", true);
|
|
toastUntil = millis() + 1200;
|
|
break;
|
|
case 1: {
|
|
Serial.println("[BTN] DONE (PLAY)");
|
|
bool ok = Api::markDone();
|
|
UI::showToast(ok ? "Hecha!" : "Error", ok);
|
|
toastUntil = millis() + 1200;
|
|
break;
|
|
}
|
|
case 2: {
|
|
Serial.println("[BTN] SNOOZE (V-)");
|
|
bool ok = Api::snooze();
|
|
UI::showToast(ok ? "Snooze 5m" : "Error", ok);
|
|
toastUntil = millis() + 1200;
|
|
break;
|
|
}
|
|
}
|
|
lastMs[i] = millis();
|
|
}
|
|
|
|
void tick() {
|
|
uint32_t now = millis();
|
|
for (uint8_t i = 0; i < 3; i++) {
|
|
bool now_low = (digitalRead(PINS[i]) == LOW); // pulsado = LOW
|
|
// Flanco descendente: idle HIGH → pulsado LOW
|
|
if (now_low && lastState[i] && (now - lastMs[i] > DEBOUNCE_MS)) {
|
|
press(i);
|
|
} else if (!now_low && !lastState[i]) {
|
|
// soltado
|
|
}
|
|
lastState[i] = now_low;
|
|
}
|
|
}
|
|
} // namespace Input
|
|
|
|
// =============================================================
|
|
// PORTAL
|
|
// =============================================================
|
|
namespace Portal {
|
|
static const char INDEX_HTML[] PROGMEM = R"HTML(
|
|
<!doctype html><html lang="es"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>DummyBot Setup</title>
|
|
<style>
|
|
:root{color-scheme:dark}*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,system-ui,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 #27272a;border-radius:16px;padding:1.5rem;box-shadow:0 20px 60px #000}
|
|
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 #27272a;color:#f4f4f5;border-radius:8px;font-size:1rem;font-family:inherit}
|
|
input:focus{outline:0;border-color:#6366f1;box-shadow:0 0 0 3px #6366f133}
|
|
button{margin-top:1rem;width:100%;padding:.65rem;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;border:0;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer}
|
|
button.sec{background:transparent;border:1px solid #3f3f46;margin-top:.5rem}
|
|
.help{font-size:.7rem;color:#71717a;margin-top:.2rem}
|
|
.div{height:1px;background:#27272a;margin:1rem 0}
|
|
.nets{margin-top:.3rem;max-height:160px;overflow-y:auto;border:1px solid #27272a;border-radius:8px}
|
|
.net{width:100%;text-align:left;padding:.6rem .7rem;background:transparent;border:0;border-bottom:1px solid #27272a;color:#f4f4f5;cursor:pointer;font-size:.9rem;display:flex;justify-content:space-between;align-items:center}
|
|
.net:hover{background:#6366f11a}.net:last-child{border-bottom:0}
|
|
.rssi{font-size:.7rem;color:#a1a1aa}.bars{font-family:monospace}
|
|
.result{margin-top:1rem;padding:.6rem;border-radius:8px;font-size:.85rem;display:none}
|
|
.ok{background:#16a34a22;color:#86efac;border:1px solid #16a34a55}
|
|
.err{background:#dc262622;color:#fca5a5;border:1px solid #dc262655}
|
|
</style></head><body><div class="c"><div class="card">
|
|
<h1>DummyBot</h1><p class="lead">Configura WiFi y token del bot</p>
|
|
<form id="f">
|
|
<label>Nombre del bot</label><input name="name" id="name" value="__NAME__">
|
|
<label>Red WiFi</label><input name="ssid" id="ssid" value="__SSID__" required>
|
|
<div class="nets" id="nets"></div>
|
|
<p class="help">Toca una red para autorrellenar. Se reescanea cada 15s.</p>
|
|
<label>Contrasena WiFi</label><input name="pass" id="pass" type="password" value="__PASS__">
|
|
<button type="button" class="sec" onclick="testConn()">Probar conexion</button>
|
|
<div id="test" class="result"></div>
|
|
<div class="div"></div>
|
|
<label>Backend URL</label><input name="backend" id="backend" value="__BACKEND__">
|
|
<label>Device Token</label><input name="token" id="token" value="__TOKEN__">
|
|
<button type="button" onclick="saveCfg()">Guardar y reiniciar</button>
|
|
</form></div></div>
|
|
<script>
|
|
function bars(r){const d=Math.max(0,Math.min(4,Math.floor((r+100)/10)));let s='';for(let i=0;i<4;i++)s+=i<d?'\u2588':'\u2003';return s}
|
|
function load(){
|
|
fetch('/networks').then(r=>r.json()).then(a=>{
|
|
const el=document.getElementById('nets');
|
|
if(!a.length){el.innerHTML='<div class="help">No se encontraron redes</div>';return}
|
|
el.innerHTML='';
|
|
a.sort((x,y)=>y.rssi-x.rssi);
|
|
a.forEach(n=>{
|
|
const b=document.createElement('button');
|
|
b.type='button';b.className='net';
|
|
b.innerHTML='<span>'+n.ssid+(n.enc?' \ud83d\udd12':'')+'</span><span class="rssi"><span class="bars">'+bars(n.rssi)+'</span> '+n.rssi+'dBm</span>';
|
|
b.onclick=function(){document.getElementById('ssid').value=n.ssid;document.querySelectorAll('.net').forEach(x=>x.style.background='');this.style.background='#6366f133'};
|
|
el.appendChild(b);
|
|
});
|
|
}).catch(()=>{document.getElementById('nets').innerHTML='<div class="help">Error al escanear</div>'})
|
|
}
|
|
async function testConn(){
|
|
const ssid=encodeURIComponent(document.getElementById('ssid').value);
|
|
const pass=encodeURIComponent(document.getElementById('pass').value);
|
|
const out=document.getElementById('test');
|
|
out.className='result';out.style.display='block';
|
|
out.innerHTML='Probando...';
|
|
try{
|
|
const r=await fetch('/test?ssid='+ssid+'&pass='+pass);
|
|
const j=await r.json();
|
|
if(j.ok){out.className='result ok';out.innerHTML='\u2713 Conectado a "'+j.ssid+'" ('+j.ip+', '+j.rssi+' dBm)'}
|
|
else{let m='\u2717 ';switch(j.reason){case 'NO_SSID_AVAIL':m+='red no encontrada';break;case 'WRONG_PASSWORD':m+='contrasena incorrecta';break;case 'TIMEOUT':m+='timeout';break;default:m+=j.reason}out.className='result err';out.innerHTML=m}
|
|
}catch(e){out.className='result err';out.innerHTML='Error: '+e.message}
|
|
}
|
|
async function saveCfg(){
|
|
const out=document.getElementById('test');
|
|
out.className='result';out.style.display='block';
|
|
out.innerHTML='Guardando...';
|
|
const fd=new FormData(document.getElementById('f'));
|
|
try{
|
|
const r=await fetch('/save',{method:'POST',body:new URLSearchParams(fd)});
|
|
if(r.ok){out.className='result ok';out.innerHTML='\u2713 Guardado. Reiniciando...'}
|
|
else{out.className='result err';out.innerHTML='Error guardando'}
|
|
}catch(e){out.className='result err';out.innerHTML='Error: '+e.message}
|
|
}
|
|
load();setInterval(load,15000);
|
|
</script></body></html>
|
|
)HTML";
|
|
|
|
String renderIndex() {
|
|
String html = FPSTR(INDEX_HTML);
|
|
html.replace("__NAME__", cfg.name);
|
|
html.replace("__SSID__", cfg.ssid);
|
|
html.replace("__PASS__", cfg.pass);
|
|
html.replace("__BACKEND__", cfg.backend);
|
|
html.replace("__TOKEN__", cfg.token);
|
|
return html;
|
|
}
|
|
|
|
void onRoot() { webServer.send(200, "text/html; charset=utf-8", renderIndex()); }
|
|
void onCaptive(){ webServer.sendHeader("Location", "http://192.168.4.1/"); webServer.send(302); }
|
|
void onNotFound(){ onCaptive(); }
|
|
|
|
void onNetworks() {
|
|
int n = WiFi.scanNetworks(false, true);
|
|
String json = "[";
|
|
for (int i = 0; i < n; i++) {
|
|
if (i > 0) json += ",";
|
|
String s = WiFi.SSID(i);
|
|
s.replace("\\", "\\\\"); s.replace("\"", "\\\"");
|
|
json += "{\"ssid\":\"" + s + "\",\"rssi\":" + String(WiFi.RSSI(i)) +
|
|
",\"enc\":" + String(WiFi.encryptionType(i) != WIFI_AUTH_OPEN ? "true" : "false") + "}";
|
|
}
|
|
json += "]";
|
|
WiFi.scanDelete();
|
|
webServer.send(200, "application/json", json);
|
|
}
|
|
|
|
void onSave() {
|
|
if (!webServer.hasArg("ssid")) { webServer.send(400, "text/html", "Falta SSID"); return; }
|
|
cfg.ssid = webServer.arg("ssid"); cfg.ssid.trim();
|
|
cfg.pass = webServer.arg("pass"); cfg.pass.trim();
|
|
cfg.token = webServer.arg("token"); cfg.token.trim();
|
|
cfg.backend = webServer.arg("backend"); cfg.backend.trim();
|
|
cfg.name = webServer.hasArg("name") ? webServer.arg("name") : "Mi DummyBot";
|
|
cfg.name.trim();
|
|
if (cfg.ssid.isEmpty()) { webServer.send(400, "text/html", "SSID vacio"); return; }
|
|
cfg.save();
|
|
String ok = R"HTML(
|
|
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<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 #27272a;border-radius:16px;padding:2rem;text-align:center}
|
|
h1{color:#86efac;margin:0 0 .5rem}.e{font-size:3.5rem;margin-bottom:1rem}p{color:#a1a1aa}
|
|
</style></head><body><div class="c"><div class="e">\u2705</div><h1>Guardado</h1>
|
|
<p>El bot se esta reiniciando.</p><p style="margin-top:1rem;color:#818cf8">\u2192 Conectate a <strong>__SSID__</strong></p>
|
|
</div></body></html>)HTML";
|
|
ok.replace("__SSID__", cfg.ssid);
|
|
webServer.send(200, "text/html; charset=utf-8", ok);
|
|
delay(500);
|
|
ESP.restart();
|
|
}
|
|
|
|
void onReset() {
|
|
cfg.clear();
|
|
webServer.sendHeader("Location", "/");
|
|
webServer.send(302);
|
|
delay(500);
|
|
ESP.restart();
|
|
}
|
|
|
|
void onTest() {
|
|
if (!webServer.hasArg("ssid")) {
|
|
webServer.send(400, "application/json", "{\"ok\":false,\"reason\":\"NO_SSID\"}");
|
|
return;
|
|
}
|
|
String ssid = webServer.arg("ssid");
|
|
String pass = webServer.hasArg("pass") ? webServer.arg("pass") : "";
|
|
webServer.stop();
|
|
dnsServer.stop();
|
|
WiFi.softAPdisconnect(true);
|
|
delay(100);
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.begin(ssid.c_str(), pass.c_str());
|
|
uint32_t t0 = millis();
|
|
wl_status_t st = WiFi.status();
|
|
while (millis() - t0 < 15000 &&
|
|
st != WL_CONNECTED && st != WL_CONNECT_FAILED && st != WL_NO_SSID_AVAIL) {
|
|
delay(200);
|
|
st = WiFi.status();
|
|
}
|
|
String json;
|
|
if (st == WL_CONNECTED) {
|
|
json = "{\"ok\":true,\"ssid\":\"" + ssid + "\",\"ip\":\"" +
|
|
WiFi.localIP().toString() + "\",\"rssi\":" + String(WiFi.RSSI()) + "}";
|
|
} else {
|
|
const char* reason = "UNKNOWN";
|
|
switch (st) {
|
|
case WL_NO_SSID_AVAIL: reason = "NO_SSID_AVAIL"; break;
|
|
case WL_CONNECT_FAILED: reason = "WRONG_PASSWORD"; break;
|
|
case WL_DISCONNECTED: reason = "TIMEOUT"; break;
|
|
default: break;
|
|
}
|
|
json = "{\"ok\":false,\"reason\":\"" + String(reason) + "\"}";
|
|
}
|
|
WiFi.disconnect(false);
|
|
delay(100);
|
|
WiFi.mode(WIFI_AP_STA);
|
|
delay(100);
|
|
WiFi.softAP(AP_NAME, AP_PASS);
|
|
dnsServer.start(53, "*", WiFi.softAPIP());
|
|
webServer.begin();
|
|
webServer.send(200, "application/json", json);
|
|
}
|
|
|
|
void start() {
|
|
WiFi.mode(WIFI_AP_STA);
|
|
WiFi.softAP(AP_NAME, AP_PASS);
|
|
dnsServer.start(53, "*", WiFi.softAPIP());
|
|
webServer.on("/", HTTP_GET, onRoot);
|
|
webServer.on("/networks", HTTP_GET, onNetworks);
|
|
webServer.on("/test", HTTP_GET, onTest);
|
|
webServer.on("/save", HTTP_POST, onSave);
|
|
webServer.on("/reset", HTTP_POST, onReset);
|
|
webServer.onNotFound(onCaptive);
|
|
webServer.begin();
|
|
Serial.printf("[PORTAL] AP %s abierto\n", AP_NAME);
|
|
}
|
|
|
|
void stop() {
|
|
webServer.stop();
|
|
dnsServer.stop();
|
|
WiFi.softAPdisconnect(true);
|
|
}
|
|
|
|
void tick() {
|
|
dnsServer.processNextRequest();
|
|
webServer.handleClient();
|
|
}
|
|
} // namespace Portal
|
|
|
|
// =============================================================
|
|
// WIFI
|
|
// =============================================================
|
|
enum class WState { BOOT, CONNECTING, CONNECTED, FAILED, PORTAL };
|
|
static WState wState = WState::BOOT;
|
|
static uint32_t wStartMs = 0;
|
|
static uint8_t wDots = 0;
|
|
|
|
void wifiStartSTA() {
|
|
if (cfg.ssid.isEmpty()) {
|
|
Portal::start();
|
|
wState = WState::PORTAL;
|
|
return;
|
|
}
|
|
Serial.printf("[WIFI] conectando a '%s'...\n", cfg.ssid.c_str());
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.disconnect(false);
|
|
WiFi.begin(cfg.ssid.c_str(), cfg.pass.c_str());
|
|
wStartMs = millis();
|
|
wState = WState::CONNECTING;
|
|
}
|
|
|
|
void wifiTick() {
|
|
switch (wState) {
|
|
case WState::CONNECTING: {
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
Serial.printf("[WIFI] OK ip=%s rssi=%d\n",
|
|
WiFi.localIP().toString().c_str(), WiFi.RSSI());
|
|
wState = WState::CONNECTED;
|
|
Api::pollPending();
|
|
} else if (millis() - wStartMs > WIFI_TIMEOUT_MS) {
|
|
Serial.println("[WIFI] timeout, abriendo portal");
|
|
Portal::start();
|
|
wState = WState::PORTAL;
|
|
} else {
|
|
if (millis() - wStartMs > 400) {
|
|
wStartMs = millis();
|
|
wDots = (wDots + 1) % 4;
|
|
UI::showConnecting(cfg.ssid, wDots);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case WState::CONNECTED: {
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
Serial.println("[WIFI] perdido, reconectando");
|
|
wState = WState::CONNECTING;
|
|
wStartMs = millis();
|
|
WiFi.reconnect();
|
|
}
|
|
break;
|
|
}
|
|
case WState::PORTAL: {
|
|
static uint32_t lastUi = 0;
|
|
if (millis() - lastUi > 2000) {
|
|
lastUi = millis();
|
|
UI::showPortal(WiFi.softAPgetStationNum());
|
|
}
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
// =============================================================
|
|
// SETUP / LOOP
|
|
// =============================================================
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
delay(300);
|
|
Serial.println("\n=== DummyBot v6.2 (botones invertidos) ===");
|
|
|
|
// Botones con INPUT_PULLUP, lógica invertida (LOW al pulsar)
|
|
pinMode(PIN_BTN_NEXT, INPUT_PULLUP); // V+
|
|
pinMode(PIN_BTN_PLAY, INPUT_PULLUP); // PLAY (DONE)
|
|
pinMode(PIN_BTN_SNOO, INPUT_PULLUP); // V- (SNOOZE)
|
|
|
|
Wire.begin(OLED_SDA, OLED_SCL);
|
|
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
|
|
Serial.println("[OLED] FAIL");
|
|
} else {
|
|
display.clearDisplay();
|
|
display.setTextSize(1);
|
|
display.setTextColor(SSD1306_WHITE);
|
|
display.setCursor(0, 0);
|
|
display.print(F("OLED OK"));
|
|
display.display();
|
|
delay(500);
|
|
UI::showBoot();
|
|
}
|
|
|
|
cfg.load();
|
|
|
|
pinMode(PIN_BOOT, INPUT_PULLUP);
|
|
if (digitalRead(PIN_BOOT) == LOW) {
|
|
uint32_t t0 = millis();
|
|
while (digitalRead(PIN_BOOT) == LOW && millis() - t0 < 5000) delay(50);
|
|
if (millis() - t0 >= 5000) { cfg.clear(); delay(200); }
|
|
}
|
|
|
|
wState = WState::CONNECTING;
|
|
wStartMs = millis();
|
|
wifiStartSTA();
|
|
}
|
|
|
|
void loop() {
|
|
Input::tick();
|
|
if (wState == WState::PORTAL) Portal::tick();
|
|
wifiTick();
|
|
if (wState == WState::CONNECTED) {
|
|
static uint32_t lastPoll = 0;
|
|
if (millis() - lastPoll > POLL_MS) {
|
|
lastPoll = millis();
|
|
if (!Api::pollPending()) UI::showIdle();
|
|
}
|
|
}
|
|
// Render principal: respeta el toast (no pisar durante 1.2s tras pulsar)
|
|
static uint32_t lastRender = 0;
|
|
if (wState == WState::CONNECTED && millis() - lastRender > 1000) {
|
|
if (millis() < toastUntil) {
|
|
delay(20);
|
|
return;
|
|
}
|
|
lastRender = millis();
|
|
if (hasTask) UI::showTask(currentTask.title, currentTask.priority, currentTask.nextDueSec);
|
|
else UI::showIdle();
|
|
}
|
|
delay(20);
|
|
}
|