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
This commit is contained in:
javiservices 2026-06-18 12:56:22 +02:00
parent 478bb65d32
commit dce87fbc11

View File

@ -1,8 +1,10 @@
// =============================================================
// DummyBot — firmware ESP32 v6.1 (producción sin botones)
// DummyBot — firmware ESP32 v6.2 (botones invertidos + sin vibrador)
// OLED SSD1306 128x64 + WiFi STA + polling backend
// Sin input físico: botones deshabilitados hasta nueva placa.
// Mantiene: WiFi config + portal cautivo + API + OLED + vibrador.
// 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>
@ -17,14 +19,16 @@
#include <Preferences.h>
// ============== HARDWARE ==============
static constexpr uint8_t PIN_VIBRATOR = 25;
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;
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
@ -37,7 +41,7 @@ static constexpr uint8_t SCREEN_H = 64;
#define BACKEND_URL "http://157.180.77.232:18080"
#endif
#ifndef DEFAULT_DEVICE_TOKEN
#define DEFAULT_DEVICE_TOKEN ""
#define DEFAULT_DEVICE_TOKEN "UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c"
#endif
static constexpr const char* AP_NAME = "DummyBot-Setup";
@ -47,8 +51,8 @@ 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 VIB_PATTERN_MS = 1500;
static constexpr uint32_t VIB_BUZZ_MS = 400;
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;
@ -134,13 +138,12 @@ void showConnecting(const String& ssid, uint8_t dots) {
display.display();
}
void showIdle(bool wifiOk) {
void showIdle() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.print(F("DummyBot "));
display.print(wifiOk ? F("[W]") : F("[!]"));
display.print(F("DummyBot [W]"));
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(8, 22);
@ -176,6 +179,22 @@ void showTask(const String& title, const String& priority, int32_t dueSec) {
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);
@ -195,23 +214,6 @@ void showPortal(uint8_t clients) {
}
} // namespace UI
// =============================================================
// VIBRADOR
// =============================================================
namespace Vib {
bool active = false;
uint32_t startMs = 0;
void buzz() { active = true; startMs = millis(); digitalWrite(PIN_VIBRATOR, HIGH); }
void stop() { active = false; digitalWrite(PIN_VIBRATOR, LOW); }
void tick() {
if (!active) return;
uint32_t e = millis() - startMs;
if (e >= VIB_PATTERN_MS) { stop(); return; }
digitalWrite(PIN_VIBRATOR, (e % (VIB_BUZZ_MS + 200)) < VIB_BUZZ_MS ? HIGH : LOW);
}
} // namespace Vib
// =============================================================
// TAREAS
// =============================================================
@ -225,6 +227,9 @@ struct Task {
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
@ -268,12 +273,87 @@ bool pollPending() {
currentTask.priority = t0["priority"] | "med";
currentTask.nextDueSec = t0["next_due_in"] | 0;
hasTask = true;
if ((int32_t)currentTask.id != lastSeenId && lastSeenId != 0) Vib::buzz();
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
// =============================================================
@ -303,7 +383,6 @@ button.sec{background:transparent;border:1px solid #3f3f46;margin-top:.5rem}
.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}
.emoji{font-size:2rem;margin-bottom:.3rem}
</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">
@ -320,7 +399,6 @@ button.sec{background:transparent;border:1px solid #3f3f46;margin-top:.5rem}
<button type="button" onclick="saveCfg()">Guardar y reiniciar</button>
</form></div></div>
<script>
let picked=null;
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=>{
@ -332,7 +410,7 @@ function load(){
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(){picked=n.ssid;document.getElementById('ssid').value=n.ssid;document.querySelectorAll('.net').forEach(x=>x.style.background='');this.style.background='#6366f133'};
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>'})
@ -347,7 +425,7 @@ async function testConn(){
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 'NO_IP':m+='sin IP';break;case 'TIMEOUT':m+='timeout';break;default:m+=j.reason}out.className='result err';out.innerHTML=m}
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(){
@ -566,15 +644,24 @@ void wifiTick() {
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("\n=== DummyBot v6.1 (sin botones) ===");
Serial.println("\n=== DummyBot v6.2 (botones invertidos) ===");
pinMode(PIN_VIBRATOR, OUTPUT);
digitalWrite(PIN_VIBRATOR, LOW);
// 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();
}
@ -593,21 +680,26 @@ void setup() {
}
void loop() {
Vib::tick();
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(true);
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(true);
else UI::showIdle();
}
delay(20);
}