dummybot-firmware/src/main.cpp
javiservices dce87fbc11 feat: firmware v6.2 botones invertidos (INPUT_PULLUP)
- 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
2026-06-18 12:56:22 +02:00

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);
}