diff --git a/README.md b/README.md index c373786..2903b4c 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,104 @@ # DummyBot Firmware -Firmware del ESP32 para **DummyBot**, task manager para TDAH. +Firmware del ESP32 para **DummyBot**, task manager para TDAH con pantalla OLED. ## Hardware - **ESP32-D0WD-V3** (DevKitC / WROOM-32, 4 MB flash) -- **Pantalla M130T-240240-RGB-7-V1.1** (ST7789 240x240 SPI, sin CS) +- **Pantalla OLED SSD1306 0.96"** (128x64, I2C, 4 pines) - **3× TTP223** (botones táctiles: Siguiente / Hecha / Snooze) - **1× motor vibrador 1027** (3V DC, controlado por MOSFET en GPIO 25) ### Cableado ``` -ESP32 (WROOM-32) M130T-240240-RGB-7-V1.1 -───────────────────── ───────────────────────── -5V (VIN) ───────▶ VCC ← IMPORTANTE: 5V, NO 3V3 -GND ───────▶ GND -GPIO 18 ───────▶ SCL (SPI clock) -GPIO 23 ───────▶ SDA (SPI MOSI) -GPIO 15 ───────▶ RES (reset) ← IMPORTANTE: GPIO 15, NO GPIO 2 -GPIO 4 ───────▶ DC (data/command) -GPIO 32 ───────▶ BL (backlight) - CS a GND (no se usa) +ESP32 (WROOM-32) OLED SSD1306 0.96" (GM009605 v4.x) +───────────────────── ───────────────────────── +3V3 ───────▶ VDD (también OK con 5V) +GND ───────▶ GND +GPIO 22 ───────▶ SCK (I2C SCL) +GPIO 21 ───────▶ SDA (I2C SDA) + +ESP32 TTP223 (×3) +───────── ─────────── +GPIO 26 ──▶ SIG TTP223 #1 Siguiente tarea +GPIO 27 ──▶ SIG TTP223 #2 Marcar como hecha +GPIO 14 ──▶ SIG TTP223 #3 Snooze 5 min +3V3 ──▶ VCC de los 3 +GND ──▶ GND de los 3 + +ESP32 Módulo vibrador (con MOSFET) +───────── ────────────────────────────── +GPIO 25 ──▶ Gate del MOSFET +3V3 o 5V ──▶ V+ del vibrador +GND ──▶ GND del vibrador ``` -#### Notas importantes sobre cableado - -1. **VCC de la pantalla en 5V (pin VIN)**, NO en 3V3. La pantalla necesita más corriente - de la que el regulador 3.3V del ESP32 puede suministrar bajo carga (WiFi + SPI). - Con 3V3 el panel se ilumina pero no muestra nada. - -2. **RES (reset) en GPIO 15**, NO en GPIO 2. GPIO 2 está compartido con el LED interno - azul del ESP32 DevKitC, lo que interfiere con la señal de reset. - -3. Los **3 botones TTP223** van a GPIO 26, 27 y 14, alimentados desde 3V3. -4. El **vibrador** va a GPIO 25 con un transistor (MOSFET AO3400 o similar). - -Para el motor 1027 (3V, 70-100 mA), un MOSFET AO3400 o 2N7000 + resistencia 100Ω en gate. Si solo tienes NPN (BC547/2N2222), esquema alternativo: motor entre 3V3 y colector, resistencia 1kΩ en base. +Para el motor 1027 (3V, 70-100 mA), un MOSFET AO3400 o 2N7000 + resistencia 100Ω en gate. Si solo tienes NPN (BC547/2N2222/S8050), esquema alternativo: motor entre 3V3 y colector, resistencia 1kΩ en base. ## Build ```bash -# 1. Compilar -pio run -e esp32dev +# 1. Instalar PlatformIO (si no lo tienes) +pip install platformio -# 2. Flashear firmware -pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0 +# 2. Compilar +pio run -# 3. Generar y flashear SPIFFS (caritas) -python3 gen_gifs.py -mkdir -p data/gifs && cp gifs/*.gif data/gifs/ -mkspiffs -c data -s 0xCF000 -b 4096 -p 256 spiffs.bin -esptool --port /dev/ttyUSB0 write_flash 0x310000 spiffs.bin +# 3. Flashear +pio run -t upload --upload-port /dev/ttyUSB0 + +# 4. Monitor serie (opcional, para ver logs) +pio device monitor --port /dev/ttyUSB0 --baud 115200 ``` -## Configurar backend + token +## Configurar -En `platformio.ini`: +Hay dos formas: +**A) Hardcodear en `platformio.ini`** (más cómodo, editas antes de flashear): ```ini -[env:mi-server] -platform = espressif32 @ 6.5.0 -board = esp32dev -framework = arduino -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 build_flags = - -DCORE_DEBUG_LEVEL=3 - -DUSER_SETUP_LOADED=1 - -include $PROJECT_DIR/include/User_Setup.h - -DWM_DEBUG_LEVEL=3 - -DBACKEND_URL=\"https://dummybot.example.com\" - -DDEVICE_TOKEN=\"1|abc...xyz\" -build_src_filter = + - + -DDEFAULT_WIFI_SSID=\"TuWiFi\" + -DDEFAULT_WIFI_PASS=\"TuPassword\" + -DBACKEND_URL=\"http://tu-servidor.com\" + -DDEFAULT_DEVICE_TOKEN=\"tu-token-del-dispositivo\" ``` -## Caritas +**B) Captive portal** (recomendado si vas a dar el bot a otra persona): +1. Flashear el firmware sin configurar. +2. En el primer arranque, el bot abre un AP `DummyBot-Setup` (pass `dummybot`). +3. Conéctate desde el móvil y abre `http://192.168.4.1/`. +4. Verás una lista de redes WiFi detectadas (con intensidad en dBm). +5. Selecciona la tuya, escribe la contraseña, el token y guarda. +6. El bot reinicia y conecta solo. -12 GIFs animados de 80x80, generados con `gen_gifs.py`: +Para resetear la configuración guardada: mantén pulsado el botón **BOOT (GPIO 0)** durante 5 s al arrancar. -- 01_neutral, 02_happy, 03_worried, 04_sad, 05_cry -- 06_surprised, 07_angry, 08_sleepy, 09_wink, 10_love -- 11_working, 12_excited +## Pantalla OLED -El firmware elige la emoción según: +La OLED muestra: -- Sin tareas → `SLEEPY` / rotando demo -- Nueva tarea detectada → `EXCITED` + vibración -- Prioridad `high` → `WORKING` -- Hecha → `WINK` -- Snooze → `WORRIED` +- **Sin tareas**: "Sin tareas :)" + "Añade desde la web" +- **Con tarea**: nombre de la tarea (en grande), prioridad en el header +- **Conectando**: "WiFi..." con puntos animados +- **Portal cautivo**: "Config WiFi" + AP + contraseña + +## Funcionalidad + +- **WiFi**: conexión automática con reconexión. Captive portal para configurar. +- **HTTP**: polling cada 30 s a `BACKEND_URL/api/device/tasks/pending` con token de dispositivo. +- **3 botones**: Siguiente (pasa a la siguiente), Hecha (marca como hecha), Snooze (pospone 5 min). +- **Vibrador**: vibra cuando llega una tarea nueva (patrón de 2 buzzes). +- **Captive portal**: lista redes WiFi detectadas con dBm, prueba conexión antes de guardar. ## Tests ```bash -# Test de pantalla (colores + chip ID ST7789) +# Test diagnóstico de pantalla (en otra rama) pio run -e diag -t upload - -# Test de bus SPI (loopback MOSI↔MISO) -pio run -e spiloop -t upload ``` ## Licencia -MIT +MIT \ No newline at end of file diff --git a/gen_gifs.py b/gen_gifs.py deleted file mode 100644 index 03007e9..0000000 --- a/gen_gifs.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -""" -Genera 12 GIFs animados de 80x80 con caritas kawaii, una por emoción. -Estilo minimalista: cara blanca, ojos negros, cejas, boca, blush rosado. -Se guardan en firmware/gifs/.gif y se suben a SPIFFS del ESP32. -""" - -import os -from PIL import Image, ImageDraw - -SIZE = 80 -OUT_DIR = os.path.join(os.path.dirname(__file__), "gifs") -os.makedirs(OUT_DIR, exist_ok=True) - -# Paleta kawaii -SKIN = (255, 230, 200, 255) # piel -WHITE = (255, 255, 255, 255) -BLACK = (20, 20, 30, 255) -PINK = (255, 150, 180, 255) # blush -LBLUE = (180, 215, 255, 255) # lágrima -RED = (220, 70, 90, 255) -GREEN = (110, 200, 130, 255) -YELLOW = (250, 200, 60, 255) -PURPLE = (180, 130, 230, 255) - - -def new_canvas(): - return Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) - - -def draw_face(draw, *, eye_y=40, eye_lx=24, eye_rx=56, - eye_w=12, eye_h=14, pupil_dx=0, pupil_dy=0, - brow_l=(20, 20, 32, 20), brow_r=(48, 20, 60, 20), - mouth_kind="neutral", open_pct=100, blush=True, - extras=None): - """Dibuja la cara con los parámetros dados. Devuelve la imagen.""" - # Borde de la cara (círculo) - draw.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) - - # Blush - if blush: - draw.ellipse([(10, 48), (24, 56)], fill=PINK) - draw.ellipse([(56, 48), (70, 56)], fill=PINK) - - # Cejas - draw.line([(brow_l[0], brow_l[1]), (brow_l[2], brow_l[3])], fill=BLACK, width=3) - draw.line([(brow_r[0], brow_r[1]), (brow_r[2], brow_r[3])], fill=BLACK, width=3) - - # Ojos - open_h = max(2, int(eye_h * open_pct / 100)) - # izquierdo - draw.ellipse([(eye_lx - eye_w // 2, eye_y - open_h // 2 + pupil_dy), - (eye_lx + eye_w // 2, eye_y + open_h // 2 + pupil_dy)], - fill=WHITE, outline=BLACK, width=2) - # derecho - draw.ellipse([(eye_rx - eye_w // 2, eye_y - open_h // 2 + pupil_dy), - (eye_rx + eye_w // 2, eye_y + open_h // 2 + pupil_dy)], - fill=WHITE, outline=BLACK, width=2) - # pupilas (solo si el ojo está abierto) - if open_pct > 30: - pdx = 3 if pupil_dx > 0 else (-3 if pupil_dx < 0 else 0) - pdy = 3 if pupil_dy > 0 else (-3 if pupil_dy < 0 else 0) - draw.ellipse([(eye_lx - 2 + pdx, eye_y - 2 + pdy), - (eye_lx + 2 + pdx, eye_y + 2 + pdy)], fill=BLACK) - draw.ellipse([(eye_rx - 2 + pdx, eye_y - 2 + pdy), - (eye_rx + 2 + pdx, eye_y + 2 + pdy)], fill=BLACK) - - # Boca - if mouth_kind == "neutral": - draw.line([(34, 60), (46, 60)], fill=BLACK, width=2) - elif mouth_kind == "happy": - draw.arc([(28, 54), (52, 70)], 10, 170, fill=BLACK, width=3) - elif mouth_kind == "open_smile": - draw.ellipse([(30, 54), (50, 70)], fill=RED, outline=BLACK, width=2) - elif mouth_kind == "sad": - draw.arc([(28, 60), (52, 76)], 190, 350, fill=BLACK, width=3) - elif mouth_kind == "frown": - draw.line([(30, 70), (50, 60)], fill=BLACK, width=2) - elif mouth_kind == "kiss": - draw.ellipse([(34, 60), (46, 68)], fill=RED, outline=BLACK, width=1) - elif mouth_kind == "diamond": - # boca de sorpresa - draw.polygon([(40, 54), (48, 62), (40, 70), (32, 62)], - fill=RED, outline=BLACK) - elif mouth_kind == "teeth": - # boca apretada - draw.rectangle([(30, 60), (50, 65)], fill=WHITE, outline=BLACK, width=1) - for x in range(34, 50, 4): - draw.line([(x, 60), (x, 65)], fill=BLACK) - elif mouth_kind == "tongue": - draw.ellipse([(30, 56), (50, 68)], fill=RED, outline=BLACK, width=2) - draw.ellipse([(36, 62), (44, 70)], fill=PINK, outline=BLACK, width=1) - elif mouth_kind == "zigzag": - # sonrisa traviesa - for x in range(30, 52, 4): - draw.line([(x, 60), (x + 3, 67)], fill=BLACK, width=2) - for x in range(30, 52, 4): - draw.line([(x + 3, 67), (x + 6, 60)], fill=BLACK, width=2) - - if extras: - extras(draw) - - -def save_anim(frames, filename, duration=80, loop=0): - """Guarda una secuencia de frames como GIF animado.""" - # Convertir a 'P' mode con paleta transparente - out = [] - for f in frames: - # Asegurar RGBA - out.append(f.convert("RGBA")) - out_path = os.path.join(OUT_DIR, filename) - out[0].save(out_path, save_all=True, append_images=out[1:], - duration=duration, loop=loop, transparency=0, - disposal=2, optimize=True) - print(f" -> {filename}: {os.path.getsize(out_path)} bytes, {len(out)} frames") - - -# ─────────────────── 1. NEUTRAL ─────────────────── -def gen_neutral(): - print("Generando 01_neutral.gif") - frames = [] - for i in range(8): - img = new_canvas() - d = ImageDraw.Draw(img) - pupil_dx = (1 if i % 2 == 0 else -1) # mirada lateral - draw_face(d, eye_y=40, pupil_dx=pupil_dx, mouth_kind="neutral", - brow_l=(28, 18, 32, 18), brow_r=(48, 18, 52, 18), - open_pct=100, blush=True) - frames.append(img) - save_anim(frames, "01_neutral.gif", duration=120) - - -# ─────────────────── 2. HAPPY ─────────────────── -def gen_happy(): - print("Generando 02_happy.gif") - frames = [] - for i in range(10): - # bounce up/down - offset = -1 if i % 2 == 0 else 1 - img = new_canvas() - d = ImageDraw.Draw(img) - d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], - fill=SKIN, outline=BLACK, width=2) - # blush - d.ellipse([(10, 48 + offset), (24, 56 + offset)], fill=PINK) - d.ellipse([(56, 48 + offset), (70, 56 + offset)], fill=PINK) - # ojos felices (arcos) - d.arc([(18, 36 + offset), (32, 50 + offset)], 200, 340, fill=BLACK, width=3) - d.arc([(48, 36 + offset), (62, 50 + offset)], 200, 340, fill=BLACK, width=3) - # boca sonrisa abierta - d.arc([(28, 54 + offset), (52, 72 + offset)], 10, 170, fill=BLACK, width=3) - frames.append(img) - save_anim(frames, "02_happy.gif", duration=80) - - -# ─────────────────── 3. WORRIED ─────────────────── -def gen_worried(): - print("Generando 03_worried.gif") - frames = [] - for i in range(10): - img = new_canvas() - d = ImageDraw.Draw(img) - # cejas en pico - draw_face(d, eye_y=42, mouth_kind="happy", - brow_l=(18, 16, 32, 22), brow_r=(48, 22, 62, 16), - open_pct=85, blush=False) - # gota de sudor - if i % 3 == 0: - d.polygon([(62, 12), (66, 18), (62, 24)], fill=LBLUE, outline=BLACK) - frames.append(img) - save_anim(frames, "03_worried.gif", duration=120) - - -# ─────────────────── 4. SAD ─────────────────── -def gen_sad(): - print("Generando 04_sad.gif") - frames = [] - for i in range(8): - img = new_canvas() - d = ImageDraw.Draw(img) - offset = 0 if i < 4 else 2 - draw_face(d, eye_y=42, mouth_kind="sad", - brow_l=(18, 20, 32, 14), brow_r=(48, 14, 62, 20), - open_pct=70, blush=False) - # lágrima cayendo - ty = 30 + offset - d.ellipse([(14, ty), (22, ty + 8)], fill=LBLUE, outline=BLACK, width=1) - frames.append(img) - save_anim(frames, "04_sad.gif", duration=150) - - -# ─────────────────── 5. CRY ─────────────────── -def gen_cry(): - print("Generando 05_cry.gif") - frames = [] - for i in range(10): - img = new_canvas() - d = ImageDraw.Draw(img) - # ojos cerrados llorando - d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) - # cejas tristes - d.line([(18, 18), (32, 24)], fill=BLACK, width=3) - d.line([(48, 24), (62, 18)], fill=BLACK, width=3) - # ojos cerrados (arcos hacia abajo) - d.arc([(18, 38), (32, 50)], 20, 160, fill=BLACK, width=3) - d.arc([(48, 38), (62, 50)], 20, 160, fill=BLACK, width=3) - # boca muy triste - d.arc([(28, 60), (52, 76)], 190, 350, fill=BLACK, width=3) - # lágrimas cayendo - t1y = 30 + (i * 3) % 30 - t2y = 35 + (i * 4) % 25 - d.ellipse([(20, t1y), (24, t1y + 5)], fill=LBLUE, outline=BLACK, width=1) - d.ellipse([(56, t2y), (60, t2y + 5)], fill=LBLUE, outline=BLACK, width=1) - frames.append(img) - save_anim(frames, "05_cry.gif", duration=100) - - -# ─────────────────── 6. SURPRISED ─────────────────── -def gen_surprised(): - print("Generando 06_surprised.gif") - frames = [] - for i in range(8): - img = new_canvas() - d = ImageDraw.Draw(img) - draw_face(d, eye_y=40, mouth_kind="diamond", - brow_l=(28, 8, 32, 8), brow_r=(48, 8, 52, 8), - open_pct=100, blush=False) - # vibración - if i % 2 == 0: - d.line([(0, 0), (10, 0)], fill=BLACK) - d.line([(0, 4), (10, 4)], fill=BLACK) - d.line([(70, 0), (80, 0)], fill=BLACK) - d.line([(70, 4), (80, 4)], fill=BLACK) - frames.append(img) - save_anim(frames, "06_surprised.gif", duration=100) - - -# ─────────────────── 7. ANGRY ─────────────────── -def gen_angry(): - print("Generando 07_angry.gif") - frames = [] - for i in range(10): - img = new_canvas() - d = ImageDraw.Draw(img) - # temblor - ox = (1 if i % 2 == 0 else -1) - oy = (1 if i % 3 == 0 else 0) - d.ellipse([(6 + ox, 6 + oy), (SIZE - 6 + ox, SIZE - 6 + oy)], - fill=SKIN, outline=BLACK, width=2) - # blush rojo - d.ellipse([(10, 48), (24, 56)], fill=RED) - d.ellipse([(56, 48), (70, 56)], fill=RED) - # cejas en pico invertido - d.line([(18, 14), (32, 24)], fill=BLACK, width=3) - d.line([(48, 24), (62, 14)], fill=BLACK, width=3) - # ojos entrecerrados - d.ellipse([(18, 38), (30, 44)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(50, 38), (62, 44)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(22, 40), (26, 42)], fill=BLACK) - d.ellipse([(54, 40), (58, 42)], fill=BLACK) - # boca fruncida - d.line([(32, 64), (48, 64)], fill=BLACK, width=3) - # vena de enfado - if i % 2 == 0: - d.line([(64, 14), (74, 24)], fill=RED, width=2) - d.line([(64, 14), (74, 14)], fill=RED, width=2) - frames.append(img) - save_anim(frames, "07_angry.gif", duration=80) - - -# ─────────────────── 8. SLEEPY ─────────────────── -def gen_sleepy(): - print("Generando 08_sleepy.gif") - frames = [] - for i in range(12): - img = new_canvas() - d = ImageDraw.Draw(img) - # cabeceo - offset = 0 if i < 6 else 4 - d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], - fill=SKIN, outline=BLACK, width=2) - # cejas relajadas - d.line([(22, 22), (28, 20)], fill=BLACK, width=2) - d.line([(52, 20), (58, 22)], fill=BLACK, width=2) - # ojos cerrados (líneas horizontales) - d.line([(18, 42), (30, 42)], fill=BLACK, width=3) - d.line([(50, 42), (62, 42)], fill=BLACK, width=3) - # ZZZ - if i >= 4: - d.text((66, 8 - offset // 2), "z", fill=BLACK) - d.text((72, 4 - offset // 2), "z", fill=BLACK) - # boca neutral - d.line([(36, 60), (44, 60)], fill=BLACK, width=2) - frames.append(img) - save_anim(frames, "08_sleepy.gif", duration=150) - - -# ─────────────────── 9. WINK ─────────────────── -def gen_wink(): - print("Generando 09_wink.gif") - frames = [] - for i in range(10): - img = new_canvas() - d = ImageDraw.Draw(img) - d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) - d.ellipse([(10, 48), (24, 56)], fill=PINK) - d.ellipse([(56, 48), (70, 56)], fill=PINK) - # ceja izquierda arqueada - d.line([(18, 18), (32, 14)], fill=BLACK, width=3) - d.line([(48, 16), (62, 22)], fill=BLACK, width=3) - # ojo izquierdo abierto, derecho cerrado (parpadeo) - if i < 5: - d.ellipse([(18, 36), (30, 50)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(22, 41), (26, 45)], fill=BLACK) - d.line([(48, 43), (62, 43)], fill=BLACK, width=3) # cerrado - else: - d.line([(18, 43), (30, 43)], fill=BLACK, width=3) # cerrado - d.ellipse([(50, 36), (62, 50)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(54, 41), (58, 45)], fill=BLACK) - # sonrisa - d.arc([(28, 54), (52, 72)], 10, 170, fill=BLACK, width=3) - # destello - if i == 2: - d.line([(60, 28), (66, 34)], fill=YELLOW, width=2) - frames.append(img) - save_anim(frames, "09_wink.gif", duration=120) - - -# ─────────────────── 10. LOVE ─────────────────── -def gen_love(): - print("Generando 10_love.gif") - frames = [] - for i in range(10): - img = new_canvas() - d = ImageDraw.Draw(img) - d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) - d.ellipse([(10, 48), (24, 56)], fill=PINK) - d.ellipse([(56, 48), (70, 56)], fill=PINK) - # ojos en forma de corazón - for cx in (24, 56): - # dos círculos + triángulo para hacer corazón pixel art - d.ellipse([(cx - 6, 36), (cx, 42)], fill=RED) - d.ellipse([(cx, 36), (cx + 6, 42)], fill=RED) - d.polygon([(cx - 6, 41), (cx + 6, 41), (cx, 50)], fill=RED) - # sonrisa - d.arc([(28, 54), (52, 72)], 10, 170, fill=BLACK, width=3) - # corazón flotante - if i % 2 == 0: - hx = 56 + (i // 2) - hy = 24 - (i // 2) - d.ellipse([(hx - 4, hy - 2), (hx, hy + 2)], fill=RED) - d.ellipse([(hx, hy - 2), (hx + 4, hy + 2)], fill=RED) - d.polygon([(hx - 4, hy + 1), (hx + 4, hy + 1), (hx, hy + 8)], fill=RED) - frames.append(img) - save_anim(frames, "10_love.gif", duration=100) - - -# ─────────────────── 11. WORKING_HARD ─────────────────── -def gen_working(): - print("Generando 11_working.gif") - frames = [] - for i in range(8): - img = new_canvas() - d = ImageDraw.Draw(img) - # cejas concentradas - d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) - d.ellipse([(10, 48), (24, 56)], fill=PINK) - d.ellipse([(56, 48), (70, 56)], fill=PINK) - d.line([(18, 14), (32, 22)], fill=BLACK, width=3) - d.line([(48, 22), (62, 14)], fill=BLACK, width=3) - # ojos con mirada fija al frente - d.ellipse([(18, 38), (30, 50)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(50, 38), (62, 50)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(22, 42), (26, 46)], fill=BLACK) - d.ellipse([(54, 42), (58, 46)], fill=BLACK) - # boca apretada - d.rectangle([(32, 60), (48, 64)], fill=WHITE, outline=BLACK, width=1) - for x in range(34, 48, 3): - d.line([(x, 60), (x, 64)], fill=BLACK) - # gotas de sudor cayendo - sy = 30 + (i * 5) % 30 - d.polygon([(64, sy - 4), (68, sy), (64, sy + 4)], fill=LBLUE, outline=BLACK, width=1) - frames.append(img) - save_anim(frames, "11_working.gif", duration=100) - - -# ─────────────────── 12. EXCITED ─────────────────── -def gen_excited(): - print("Generando 12_excited.gif") - frames = [] - for i in range(10): - # bounce fuerte - offset = -3 if i % 2 == 0 else 3 - img = new_canvas() - d = ImageDraw.Draw(img) - d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], - fill=SKIN, outline=BLACK, width=2) - d.ellipse([(10, 48 + offset), (24, 56 + offset)], fill=PINK) - d.ellipse([(56, 48 + offset), (70, 56 + offset)], fill=PINK) - # ojos grandes abiertos - d.ellipse([(16, 36 + offset), (32, 52 + offset)], fill=WHITE, outline=BLACK, width=2) - d.ellipse([(48, 36 + offset), (64, 52 + offset)], fill=WHITE, outline=BLACK, width=2) - # pupilas bailando - pdx = -2 if i % 2 == 0 else 2 - d.ellipse([(22 + pdx, 42 + offset), (26 + pdx, 46 + offset)], fill=BLACK) - d.ellipse([(54 + pdx, 42 + offset), (58 + pdx, 46 + offset)], fill=BLACK) - # boca enorme - d.ellipse([(28, 54 + offset), (52, 70 + offset)], fill=RED, outline=BLACK, width=2) - # lengua - d.ellipse([(34, 62 + offset), (46, 70 + offset)], fill=PINK) - # destellos - if i % 2 == 0: - d.line([(8, 8), (16, 16)], fill=YELLOW, width=2) - d.line([(64, 8), (72, 16)], fill=YELLOW, width=2) - frames.append(img) - save_anim(frames, "12_excited.gif", duration=70) - - -# ─────────────────── MAIN ─────────────────── -if __name__ == "__main__": - print(f"Generando 12 GIFs en {OUT_DIR}/") - gen_neutral() - gen_happy() - gen_worried() - gen_sad() - gen_cry() - gen_surprised() - gen_angry() - gen_sleepy() - gen_wink() - gen_love() - gen_working() - gen_excited() - print(f"\nListo. {os.listdir(OUT_DIR)}") - # Tamaño total - total = sum(os.path.getsize(os.path.join(OUT_DIR, f)) for f in os.listdir(OUT_DIR)) - print(f"Tamaño total: {total} bytes ({total / 1024:.1f} KB)") diff --git a/include/User_Setup.h b/include/User_Setup.h deleted file mode 100644 index 5cb1ad1..0000000 --- a/include/User_Setup.h +++ /dev/null @@ -1,47 +0,0 @@ -// ============================================================ -// User_Setup.h – TFT_eSPI config para M130T-240240-RGB-7-V1.1 -// Driver: ST7789, 240x240, SPI, sin CS (CS=GN) -// ============================================================ - -#define USER_SETUP_LOADED - -// --- Pines ESP32 (WROOM-32 / DevKitC) --- -// Los TTP223 (3 unidades) van en BTN_NEXT/BTN_DONE/BTN_SNOOZE -// y el vibrador en VIBRATOR_PIN (definidos en main.cpp). - -#pragma once - -#define TFT_MOSI 23 -#define TFT_SCLK 18 -#define TFT_CS -1 // CS no se usa (va a GND en el módulo) -#define TFT_DC 4 // Data/Command -#define TFT_RST 15 // Reset (movido desde GPIO 2 por conflicto con LED interno) -#define TFT_BL 32 // Backlight (LED) – PWM opcional - -#define TFT_SPI_FREQUENCY 40000000 -#define TFT_SPI_READ_FREQUENCY 20000000 - -// --- Driver y resolución --- -#define ST7789_DRIVER -#define TFT_WIDTH 240 -#define TFT_HEIGHT 240 - -// Offsets típicos para ST7789 240x240 (varían por lote, ajustar si sale desplazado) -#define TFT_ROTATION 0 -#define CGRAM_OFFSET // necessário para ST7789 -#define ST7789_GAMMA_CTRL1 0xD0 -#define ST7789_GAMMA_CTRL2 0x04 -#define ST7789_GAMMA_CTRL3 0x0D -#define ST7789_GAMMA_CTRL4 0x11 -#define ST7789_GAMMA_CTRL5 0x0C -#define ST7789_GAMMA_CTRL6 0x27 -#define ST7789_GAMMA_CTRL7 0x32 -#define ST7789_GAMMA_CTRL8 0x4D -#define ST7789_GAMMA_CTRL9 0x42 -#define ST7789_GAMMA_CTRL10 0x16 - -// --- Fuentes y sprites --- -#define SMOOTH_FONT -#define SPI_FREQUENCY 40000000 -#define SPI_READ_FREQUENCY 20000000 -#define SPI_TOUCH_FREQUENCY 2500000 diff --git a/platformio.ini b/platformio.ini index a564b78..846250f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ ; PlatformIO Project Configuration File -; DummyBot – task manager ESP32 +; DummyBot v5 – ESP32 + OLED SSD1306 128x64 (I2C) + 3 botones + vibrador [env:esp32dev] platform = espressif32 @ 6.5.0 @@ -7,42 +7,20 @@ board = esp32dev framework = arduino monitor_speed = 115200 upload_speed = 921600 -board_build.partitions = partitions.csv -; Libs mínimas (sin GIFs ni WiFiManager) lib_deps = - bodmer/TFT_eSPI@^2.5.43 + adafruit/Adafruit SSD1306@^2.5.7 + adafruit/Adafruit GFX Library@^1.11.9 bblanchon/ArduinoJson@^7.0.4 build_flags = -DCORE_DEBUG_LEVEL=3 -DUSER_SETUP_LOADED=1 - -include $PROJECT_DIR/include/User_Setup.h ; ─── CONFIG POR DEFECTO ─── - ; Los defaults están en main.cpp (DEFAULT_WIFI_SSID, etc). - ; Si quieres cambiarlos sin tocar código, define aquí las macros: - ; -DDEFAULT_WIFI_SSID=\"otro\" -DDEFAULT_WIFI_PASS=\"otro\" - ; - ; Backend y token (sobreescribibles vía portal web) + -DDEFAULT_WIFI_SSID=\"BatKuevaUp\" + -DDEFAULT_WIFI_PASS=\"JaMon2026\" -DBACKEND_URL=\"http://157.180.77.232:18080\" - -DDEFAULT_DEVICE_TOKEN=\"tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU\" + -DDEFAULT_DEVICE_TOKEN=\"UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c\" -build_src_filter = + - - -; ───────────────────────────────────────────────────────────── -; Entorno de DIAGNÓSTICO: solo compila diag.cpp -; ───────────────────────────────────────────────────────────── -[env:diag] -platform = espressif32 @ 6.5.0 -board = esp32dev -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 -lib_deps = - bodmer/TFT_eSPI@^2.5.43 -build_flags = - -DCORE_DEBUG_LEVEL=3 - -DUSER_SETUP_LOADED=1 - -include $PROJECT_DIR/include/User_Setup.h -build_src_filter = + \ No newline at end of file +build_src_filter = + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 1ce0be6..69c19df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,37 +1,37 @@ // ============================================================ -// DummyBot – firmware ESP32 v4 (TEXTO + Portal) -// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240) -// - ** IMPORTANTE: VCC de la pantalla en 5V (VIN), no 3V3 ** -// - ** IMPORTANTE: RST de la pantalla en GPIO 15, no GPIO 2 ** -// - 3x TTP223 (Siguiente / Hecho / Snooze) -// - 1x vibrador (GPIO 25) -// - WiFi (con reconexión) + HTTP polling al backend Laravel -// - Captive portal SIN GIFs (texto puro) +// DummyBot – firmware ESP32 v5 +// OLED SSD1306 128x64 (I2C) + WiFi + 3 botones + vibrador // ============================================================ #include -#include -#include -#include +#include +#include +#include #include #include #include #include #include +#include // -------------------- Pines -------------------- #define PIN_BTN_NEXT 26 #define PIN_BTN_DONE 27 #define PIN_BTN_SNOOZE 14 #define PIN_VIBRATOR 25 -#define PIN_TFT_BL 32 + +#define SCREEN_W 128 +#define SCREEN_H 64 +#define OLED_ADDR 0x3C +#define SDA_PIN 21 +#define SCL_PIN 22 // -------------------- Config -------------------- -#define POLL_MS 30000 -#define VIB_BUZZ_MS 400 -#define VIB_PATTERN_MS 1500 -#define SNOOZE_MINUTES 5 -#define API_TIMEOUT_MS 4000 +#define POLL_MS 30000 +#define VIB_BUZZ_MS 400 +#define VIB_PATTERN_MS 1500 +#define SNOOZE_MINUTES 5 +#define API_TIMEOUT_MS 4000 const char* WIFI_AP_NAME = "DummyBot-Setup"; const char* WIFI_AP_PASS = "dummybot"; @@ -48,11 +48,11 @@ const char* WIFI_AP_PASS = "dummybot"; #endif #ifndef DEFAULT_DEVICE_TOKEN -#define DEFAULT_DEVICE_TOKEN "tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU" +#define DEFAULT_DEVICE_TOKEN "UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c" #endif // -------------------- Estado global -------------------- -TFT_eSPI tft = TFT_eSPI(); +Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1); WebServer web(80); DNSServer dnsServer; @@ -84,6 +84,11 @@ char cfgBackend[128] = ""; char cfgName[40] = "Mi DummyBot"; bool portalActive = false; +// WiFi status +enum WifiState { W_BOOTING, W_CONNECTING, W_CONNECTED, W_FAILED, W_PORTAL }; +WifiState wifiState = W_BOOTING; +uint32_t wifiStart = 0; + // -------------------- Vib -------------------- static void startVib() { vibActive = true; @@ -103,94 +108,98 @@ static void updateVib() { } // -------------------- UI -------------------- -static void drawHeader(const char *title) { - tft.fillRect(0, 0, 240, 22, TFT_NAVY); - tft.setTextColor(TFT_WHITE, TFT_NAVY); - tft.setTextSize(1); - tft.setTextDatum(TL_DATUM); - tft.drawString(title, 6, 6, 2); -} -static void drawFooter(const char *line) { - tft.fillRect(0, 218, 240, 22, TFT_DARKGREY); - tft.setTextColor(TFT_WHITE, TFT_DARKGREY); - tft.setTextSize(1); - tft.setTextDatum(BC_DATUM); - tft.drawString(line, 120, 230, 1); -} -static void drawWifiIcon(int16_t x, int16_t y, bool connected) { - uint16_t color = connected ? TFT_GREEN : TFT_RED; - tft.fillCircle(x, y, 4, color); +static void drawCentered(const char *s, int y, int size = 1) { + int16_t x1, y1; + uint16_t w, h; + display.getTextBounds(s, 0, 0, &x1, &y1, &w, &h); + display.setCursor((SCREEN_W - w) / 2, y); + display.print(s); } static void drawIdleScreen() { - tft.fillScreen(TFT_BLACK); - drawHeader("DummyBot"); - drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(2); - tft.drawString("Sin tareas :)", 120, 100, 4); - tft.setTextSize(1); - tft.drawString("Anade desde la web", 120, 140, 2); - if (WiFi.status() != WL_CONNECTED) { - tft.setTextColor(TFT_YELLOW, TFT_BLACK); - tft.drawString("WiFi: sin conexion", 120, 170, 2); + // Header + display.setCursor(0, 0); + display.print(F("DummyBot")); + // WiFi indicator + if (WiFi.status() == WL_CONNECTED) { + display.print(F(" [W]")); } else { - tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); - tft.drawString(WiFi.localIP().toString().c_str(), 120, 170, 2); + display.print(F(" [-]")); } - drawFooter("Snooze | Hecha | Siguiente"); + display.drawLine(0, 10, SCREEN_W - 1, 10, SSD1306_WHITE); + + // Mensaje + drawCentered("Sin tareas :)", 25, 2); + + if (hasTask) { + // Hay tarea pero no se muestra + drawCentered("OK", 50, 1); + } else { + drawCentered("Anade desde la web", 50, 1); + } + display.display(); } static void drawTaskScreen() { - tft.fillScreen(TFT_BLACK); - drawHeader("DummyBot - tarea"); - drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); - uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED : - (strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW; - tft.fillRect(0, 22, 240, 14, c); - tft.setTextColor(TFT_BLACK, c); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(1); - String prio = String("PRIORIDAD ") + currentTask.priority; - tft.drawString(prio, 120, 24, 2); + // Header con prioridad + display.setCursor(0, 0); + display.printf("DummyBot [%s]", currentTask.priority); + display.drawLine(0, 10, SCREEN_W - 1, 10, SSD1306_WHITE); - const char* emoji = "(._.)"; - if (strcmp(currentTask.priority, "high") == 0) emoji = "/(>_<)\\"; - else if (strcmp(currentTask.priority, "low") == 0) emoji = "^_^"; - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(4); - tft.drawString(emoji, 120, 80, 4); - - tft.setTextSize(2); - tft.setTextDatum(TL_DATUM); + // Título (con wrap manual; 16 chars por línea a size 2) + display.setTextSize(2); + display.setCursor(0, 18); String title = String(currentTask.title); - int y = 130; - int maxChars = 16; - int idx = 0; - while (idx < (int)title.length() && y < 200) { - tft.drawString(title.substring(idx, idx + maxChars), 8, y, 4); - idx += maxChars; - y += 28; + if (title.length() > 16) { + display.print(title.substring(0, 16)); + display.setCursor(0, 38); + display.print(title.substring(16, 32)); + } else { + display.print(title); } - drawFooter("Snooze | Hecha | Siguiente"); + // Footer con info + display.setTextSize(1); + display.setCursor(0, 56); + if (currentTask.next_due_in > 60) { + int mins = currentTask.next_due_in / 60; + display.printf("Vence: %d min", mins); + } else if (currentTask.next_due_in > 0) { + display.printf("Vence: %d s", currentTask.next_due_in); + } else { + display.print(F("Snooze | Hecha | Next")); + } + display.display(); } static void drawWifiScreen(const char *msg) { - tft.fillScreen(TFT_BLACK); - drawHeader("DummyBot - WiFi"); - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(2); - tft.drawString(msg, 120, 80, 2); - tft.setTextSize(1); - tft.drawString(WIFI_AP_NAME, 120, 115, 2); - tft.drawString(WIFI_AP_PASS, 120, 135, 2); - drawFooter("conecta el movil para configurar"); + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); + display.setCursor(0, 0); + display.println(F("DummyBot - WiFi")); + display.drawLine(0, 10, SCREEN_W - 1, 10, SSD1306_WHITE); + + display.setCursor(0, 18); + display.println(msg); + + display.setCursor(0, 32); + display.printf("AP: %s", WIFI_AP_NAME); + + display.setCursor(0, 42); + display.printf("Pass: %s", WIFI_AP_PASS); + + display.setCursor(0, 56); + display.println(F("Abre 192.168.4.1")); + display.display(); } // -------------------- HTTP -------------------- @@ -303,7 +312,7 @@ static void loadConfig() { strncpy(cfgToken, doc["token"] | DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1); strncpy(cfgBackend, doc["backend"] | BACKEND_URL, sizeof(cfgBackend) - 1); strncpy(cfgName, doc["name"] | "Mi DummyBot", sizeof(cfgName) - 1); - Serial.printf("Config cargada de SPIFFS: SSID='%s'\n", cfgSsid); + Serial.printf("Config cargada: SSID='%s'\n", cfgSsid); } f.close(); } @@ -318,15 +327,79 @@ static void saveConfig() { doc["name"] = cfgName; serializeJson(doc, f); f.close(); - Serial.println("Config guardada en SPIFFS"); + Serial.println("Config guardada"); } static void resetConfig() { if (SPIFFS.exists(WIFI_CFG_PATH)) SPIFFS.remove(WIFI_CFG_PATH); cfgSsid[0] = cfgPass[0] = cfgToken[0] = cfgBackend[0] = 0; } -// -------------------- Portal -------------------- -static const char PORTAL_HTML[] PROGMEM = R"=====(DummyBot
🤖

DummyBot

Configura tu bot. Solo lo harás una vez.

Solo redes 2.4 GHz
En la web: Ajustes → Dispositivos
)====="; +// -------------------- Captive portal -------------------- +static const char PORTAL_HTML[] PROGMEM = R"=====(DummyBot
🤖

DummyBot

Configura tu bot. Solo lo harás una vez.

Buscando redes...
Si no ves tu red, escríbela abajo
Ajustes → Dispositivos
)====="; static String renderPortal() { String html = FPSTR(PORTAL_HTML); @@ -378,14 +451,81 @@ static void handleReset() { ESP.restart(); } +static void handleNetworks() { + // Scan: cambiar a STA momentáneamente, escanear, volver a AP + int n = WiFi.scanNetworks(false, true); // async=false, show_hidden=true + String json = "["; + for (int i = 0; i < n; i++) { + if (i > 0) json += ","; + // Escape básico del SSID + String ssid = WiFi.SSID(i); + ssid.replace("\\", "\\\\"); + ssid.replace("\"", "\\\""); + json += "{\"ssid\":\"" + ssid + "\","; + json += "\"rssi\":" + String(WiFi.RSSI(i)) + ","; + json += "\"enc\":" + String(WiFi.encryptionType(i) != WIFI_AUTH_OPEN ? "true" : "false") + "}"; + } + json += "]"; + WiFi.scanDelete(); + web.send(200, "application/json", json); +} + +static void handleTest() { + if (!web.hasArg("ssid")) { + web.send(400, "application/json", "{\"ok\":false,\"reason\":\"NO_SSID\"}"); + return; + } + String ssid = web.arg("ssid"); + String pass = web.hasArg("pass") ? web.arg("pass") : ""; + + // Parar cualquier conexión actual + WiFi.disconnect(); + delay(100); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), pass.c_str()); + + // Esperar hasta 15 s + uint32_t start = millis(); + wl_status_t st = WiFi.status(); + while (millis() - start < 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 { + String reason = "UNKNOWN"; + switch (st) { + case WL_NO_SSID_AVAIL: reason = "NO_SSID_AVAIL"; break; + case WL_CONNECT_FAILED: reason = "WRONG_PASSWORD"; break; + case WL_CONNECTION_LOST: reason = "LOST"; break; + case WL_DISCONNECTED: reason = "TIMEOUT"; break; + default: break; + } + json = "{\"ok\":false,\"reason\":\"" + reason + "\"}"; + } + + // Resetear para volver al portal + WiFi.disconnect(); + delay(100); + WiFi.mode(WIFI_AP); + + web.send(200, "application/json", json); +} + static void startPortal() { WiFi.mode(WIFI_AP); WiFi.softAP(WIFI_AP_NAME, WIFI_AP_PASS); Serial.printf("AP abierto: %s\n", WIFI_AP_NAME); dnsServer.start(53, "*", WiFi.softAPIP()); - web.on("/", HTTP_GET, handleRoot); - web.on("/save", HTTP_POST, handleSave); - web.on("/reset", HTTP_POST, handleReset); + web.on("/", HTTP_GET, handleRoot); + web.on("/networks", HTTP_GET, handleNetworks); + web.on("/test", HTTP_GET, handleTest); + web.on("/save", HTTP_POST, handleSave); + web.on("/reset", HTTP_POST, handleReset); web.onNotFound(handleCaptive); web.begin(); portalActive = true; @@ -413,26 +553,36 @@ static void startSTA() { void setup() { Serial.begin(115200); delay(500); - Serial.println("\n=== DummyBot v4 ==="); + Serial.println("\n=== DummyBot v5 (OLED) ==="); 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); - Serial.println("Init TFT..."); - tft.init(); - tft.setRotation(0); - Serial.println("TFT OK"); + // I2C + OLED + Wire.begin(SDA_PIN, SCL_PIN); + if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { + Serial.println("OLED FAIL"); + while (1) delay(1000); + } + Serial.println("OLED OK"); if (!SPIFFS.begin(true)) Serial.println("SPIFFS ERROR"); loadConfig(); if (!cfgBackend[0]) strncpy(cfgBackend, BACKEND_URL, sizeof(cfgBackend) - 1); if (!cfgToken[0]) strncpy(cfgToken, DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1); + // Pantalla de bienvenida + display.clearDisplay(); + display.setTextSize(2); + display.setTextColor(SSD1306_WHITE); + drawCentered("DummyBot", 16, 2); + display.setTextSize(1); + drawCentered("Iniciando...", 50, 1); + display.display(); + // Reset con BOOT 5s pinMode(0, INPUT_PULLUP); if (digitalRead(0) == LOW) { @@ -444,27 +594,15 @@ void setup() { } } - // Pantalla de bienvenida - tft.fillScreen(TFT_BLACK); - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(3); - tft.drawString("DummyBot", 120, 100, 4); - tft.setTextSize(1); - tft.drawString("Iniciando...", 120, 140, 2); - + wifiState = W_CONNECTING; + wifiStart = millis(); startSTA(); - Serial.println("Setup OK"); } -enum RunMode { MODE_CONNECTING, MODE_PORTAL, MODE_RUNNING }; -RunMode mode = MODE_CONNECTING; -uint32_t modeStart = 0; - void loop() { updateVib(); - if (mode == MODE_PORTAL) { + if (wifiState == W_PORTAL) { dnsServer.processNextRequest(); web.handleClient(); handleButtons(); @@ -483,38 +621,39 @@ void loop() { uint32_t now = millis(); - if (mode == MODE_CONNECTING) { + if (wifiState == W_CONNECTING) { if (WiFi.status() == WL_CONNECTED) { - mode = MODE_RUNNING; - modeStart = now; + wifiState = W_CONNECTED; Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str()); - drawIdleScreen(); lastPoll = now; pollTasks(); - } else if (now - modeStart > 25000) { + } else if (now - wifiStart > 25000) { Serial.println("WiFi falló, abriendo portal..."); startPortal(); - mode = MODE_PORTAL; - } else if (now - modeStart > 1000) { + wifiState = W_PORTAL; + } else if (now - wifiStart > 500) { static int dots = 0; dots = (dots + 1) % 4; - tft.fillScreen(TFT_BLACK); - tft.setTextColor(TFT_WHITE, TFT_BLACK); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(2); + display.clearDisplay(); + display.setTextSize(2); + display.setTextColor(SSD1306_WHITE); + drawCentered("WiFi...", 16, 2); + display.setTextSize(1); char msg[20]; - snprintf(msg, sizeof(msg), "WiFi %.*s", dots, "..."); - tft.drawString(msg, 120, 100, 2); - tft.setTextSize(1); - tft.drawString(cfgSsid, 120, 140, 2); + snprintf(msg, sizeof(msg), "%.*s", dots, "..."); + display.setCursor(0, 50); + display.print(msg); + display.print(F(" ")); + display.println(cfgSsid); + display.display(); } return; } if (WiFi.status() != WL_CONNECTED) { - Serial.println("WiFi perdido, reintentando..."); - mode = MODE_CONNECTING; - modeStart = now; + Serial.println("WiFi perdido"); + wifiState = W_CONNECTING; + wifiStart = now; startSTA(); return; } diff --git a/src/tests/diag.cpp b/src/tests/diag.cpp deleted file mode 100644 index b1df232..0000000 --- a/src/tests/diag.cpp +++ /dev/null @@ -1,114 +0,0 @@ -// ============================================================ -// diag.cpp – diagnóstico de pantalla para M130T-240240-RGB-7-V1.1 -// Compilar y flashear con: -// pio run -t upload --upload-port /dev/ttyUSB0 -// pio device monitor --port /dev/ttyUSB0 --baud 115200 -// Lo que hace, paso a paso, con pausas de 3 s: -// 1. Enciende backlight a PWM=255 (100%) -> descartar BL -// 2. Pinta rojo, verde, azul, blanco, negro -> descartar panel -// 3. Lee RDDID del ST7789 (chip ID debe ser 0x52) -> descartar SPI -// 4. Pinta texto "OK" centrado -> sanity final -// ============================================================ - -#include -#include -#include - -// Pines (los mismos del proyecto) -#define TFT_BL_PIN 32 -#define TFT_DC_PIN 4 -#define TFT_RST_PIN 2 - -TFT_eSPI tft = TFT_eSPI(); - -static void step(int n, const char *label) { - Serial.printf("\n[STEP %d] %s\n", n, label); - Serial.flush(); -} - -static void testBacklight() { - step(1, "Backlight PWM 0 -> 255"); - ledcSetup(0, 5000, 8); - ledcAttachPin(TFT_BL_PIN, 0); - for (int d = 0; d <= 255; d += 15) { - ledcWrite(0, d); - delay(60); - } - ledcWrite(0, 255); // 100% y se queda ahí - delay(500); - Serial.println(" backlight al 100% (si no ves nada, el problema es BL o cable BL)"); - Serial.flush(); -} - -static void testColors() { - step(2, "Pintando colores sólidos"); - const uint16_t colors[] = { TFT_RED, TFT_GREEN, TFT_BLUE, TFT_WHITE, TFT_BLACK, TFT_YELLOW, TFT_CYAN, TFT_MAGENTA }; - const char *names[] = { "RED", "GREEN", "BLUE", "WHITE", "BLACK", "YELLOW", "CYAN", "MAGENTA" }; - for (int i = 0; i < 8; i++) { - tft.fillScreen(colors[i]); - tft.setTextColor((colors[i] == TFT_BLACK) ? TFT_WHITE : TFT_BLACK, colors[i]); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(4); - tft.drawString(names[i], 120, 120, 4); - Serial.printf(" %s\n", names[i]); - Serial.flush(); - delay(800); - } -} - -static void testSPI() { - step(3, "Leyendo RDDID del ST7789 (debe ser 0x52)"); - digitalWrite(TFT_DC_PIN, LOW); - SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); - SPI.transfer(0x04); // RDDID - uint8_t b1 = SPI.transfer(0x00); - uint8_t b2 = SPI.transfer(0x00); - uint8_t b3 = SPI.transfer(0x00); - SPI.endTransaction(); - Serial.printf(" Leido: 0x%02X 0x%02X 0x%02X (esperado: 0x00 0x00 0x52)\n", b1, b2, b3); - Serial.printf(" chip_id = 0x%02X -> %s\n", b3, (b3 == 0x52) ? "ST7789 OK" : "ST7789 NO detectado"); - Serial.flush(); -} - -static void testFinal() { - step(4, "Pantalla final"); - tft.fillScreen(TFT_NAVY); - tft.setTextColor(TFT_WHITE, TFT_NAVY); - tft.setTextDatum(MC_DATUM); - tft.setTextSize(3); - tft.drawString("DummyBot", 120, 80, 4); - tft.setTextSize(2); - tft.drawString("pantalla OK", 120, 140, 2); - tft.setTextSize(1); - tft.drawString("(si ves esto, todo va bien)", 120, 175, 2); - Serial.println(" diagnóstico completo"); - Serial.flush(); -} - -void setup() { - Serial.begin(115200); - delay(1000); - Serial.println("\n=== DummyBot DIAG v1 ==="); - Serial.printf("Pines: BL=%d, DC=%d, RST=%d\n", TFT_BL_PIN, TFT_DC_PIN, TFT_RST_PIN); - Serial.flush(); - - pinMode(TFT_BL_PIN, OUTPUT); - pinMode(TFT_DC_PIN, OUTPUT); - pinMode(TFT_RST_PIN, OUTPUT); - - tft.init(); - tft.setRotation(0); - tft.fillScreen(TFT_BLACK); - - testBacklight(); - delay(1500); - testColors(); - delay(1500); - testSPI(); - delay(1500); - testFinal(); -} - -void loop() { - delay(1000); -} diff --git a/src/tests/spi_loopback.cpp b/src/tests/spi_loopback.cpp deleted file mode 100644 index a55834f..0000000 --- a/src/tests/spi_loopback.cpp +++ /dev/null @@ -1,74 +0,0 @@ -// ============================================================ -// spi_loopback.cpp – test de SPI con loopback MOSI↔MISO -// Conecta con un cable: -// GPIO 23 (MOSI) <-> GPIO 19 (MISO) -// -// Resultado esperado: TX == RX -// Si TX != RX, el bus SPI del ESP32 está mal (casi imposible). -// Si TX == RX, el problema es 100% cableado de la pantalla. -// ============================================================ - -#include -#include - -#define MOSI_PIN 23 -#define MISO_PIN 19 -#define SCK_PIN 18 -#define CS_PIN 5 // cualquiera que no se use, pongo a HIGH - -void setup() { - Serial.begin(115200); - delay(1000); - Serial.println("\n=== DummyBot SPI LOOPBACK TEST ==="); - Serial.printf("MOSI=%d MISO=%d SCK=%d CS=%d\n", MOSI_PIN, MISO_PIN, SCK_PIN, CS_PIN); - Serial.println("Conecta con un cable GPIO 23 <-> GPIO 19 y resetea la placa."); - Serial.flush(); - - pinMode(CS_PIN, OUTPUT); - digitalWrite(CS_PIN, HIGH); - - SPI.begin(SCK_PIN, MISO_PIN, MOSI_PIN, CS_PIN); - - // Test 1: enviar 0xA5 y leerlo - Serial.println("\n[TEST 1] Enviando 0xA5..."); - SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); - uint8_t rx = SPI.transfer(0xA5); - SPI.endTransaction(); - Serial.printf(" Enviado 0xA5, recibido 0x%02X -> %s\n", - rx, (rx == 0xA5) ? "OK" : "FALLO (¿cable MOSI-MISO?)"); - - // Test 2: ristra de 16 bytes - Serial.println("\n[TEST 2] Enviando 16 bytes..."); - uint8_t tx[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; - uint8_t rxbuf[16] = {0}; - SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); - for (int i = 0; i < 16; i++) rxbuf[i] = SPI.transfer(tx[i]); - SPI.endTransaction(); - bool match = true; - for (int i = 0; i < 16; i++) { - if (rxbuf[i] != tx[i]) { - match = false; - Serial.printf(" [%2d] TX=0x%02X RX=0x%02X <- MISMATCH\n", i, tx[i], rxbuf[i]); - } - } - Serial.printf(" %s\n", match ? "TODOS IGUALES -> SPI del ESP32 OK" : "HAY DIFERENCIAS -> SPI del ESP32 ROTO (rarísimo)"); - - // Test 3: misma operación con la frecuencia del display (40 MHz) - Serial.println("\n[TEST 3] Mismo test @ 40 MHz..."); - bool match2 = true; - SPI.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0)); - for (int i = 0; i < 16; i++) { - uint8_t v = SPI.transfer(tx[i]); - if (v != tx[i]) { match2 = false; break; } - } - SPI.endTransaction(); - Serial.printf(" %s\n", match2 ? "OK a 40 MHz" : "FALLO a 40 MHz (prueba 10 MHz o 20 MHz)"); - - Serial.println("\n=== FIN ==="); - Serial.flush(); -} - -void loop() { - delay(1000); -}