// ============================================================= // 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 #include #include #include #include #include #include #include #include #include // ============== 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(); 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( DummyBot Setup

DummyBot

Configura WiFi y token del bot

Toca una red para autorrellenar. Se reescanea cada 15s.

)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(
\u2705

Guardado

El bot se esta reiniciando.

\u2192 Conectate a __SSID__

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