v5: OLED SSD1306 128x64 (I2C), captive portal con WiFi scanner y test de conexión

This commit is contained in:
javiservices 2026-06-17 14:04:03 +02:00
parent fe08f175e7
commit 9fb87a4d62
7 changed files with 346 additions and 907 deletions

130
README.md
View File

@ -1,108 +1,102 @@
# DummyBot Firmware # DummyBot Firmware
Firmware del ESP32 para **DummyBot**, task manager para TDAH. Firmware del ESP32 para **DummyBot**, task manager para TDAH con pantalla OLED.
## Hardware ## Hardware
- **ESP32-D0WD-V3** (DevKitC / WROOM-32, 4 MB flash) - **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) - **3× TTP223** (botones táctiles: Siguiente / Hecha / Snooze)
- **1× motor vibrador 1027** (3V DC, controlado por MOSFET en GPIO 25) - **1× motor vibrador 1027** (3V DC, controlado por MOSFET en GPIO 25)
### Cableado ### Cableado
``` ```
ESP32 (WROOM-32) M130T-240240-RGB-7-V1.1 ESP32 (WROOM-32) OLED SSD1306 0.96" (GM009605 v4.x)
───────────────────── ───────────────────────── ───────────────────── ─────────────────────────
5V (VIN) ───────▶ VCC ← IMPORTANTE: 5V, NO 3V3 3V3 ───────▶ VDD (también OK con 5V)
GND ───────▶ GND GND ───────▶ GND
GPIO 18 ───────▶ SCL (SPI clock) GPIO 22 ───────▶ SCK (I2C SCL)
GPIO 23 ───────▶ SDA (SPI MOSI) GPIO 21 ───────▶ SDA (I2C SDA)
GPIO 15 ───────▶ RES (reset) ← IMPORTANTE: GPIO 15, NO GPIO 2
GPIO 4 ───────▶ DC (data/command) ESP32 TTP223 (×3)
GPIO 32 ───────▶ BL (backlight) ───────── ───────────
CS a GND (no se usa) 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 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.
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.
## Build ## Build
```bash ```bash
# 1. Compilar # 1. Instalar PlatformIO (si no lo tienes)
pio run -e esp32dev pip install platformio
# 2. Flashear firmware # 2. Compilar
pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0 pio run
# 3. Generar y flashear SPIFFS (caritas) # 3. Flashear
python3 gen_gifs.py pio run -t upload --upload-port /dev/ttyUSB0
mkdir -p data/gifs && cp gifs/*.gif data/gifs/
mkspiffs -c data -s 0xCF000 -b 4096 -p 256 spiffs.bin # 4. Monitor serie (opcional, para ver logs)
esptool --port /dev/ttyUSB0 write_flash 0x310000 spiffs.bin 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 ```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 = build_flags =
-DCORE_DEBUG_LEVEL=3 -DDEFAULT_WIFI_SSID=\"TuWiFi\"
-DUSER_SETUP_LOADED=1 -DDEFAULT_WIFI_PASS=\"TuPassword\"
-include $PROJECT_DIR/include/User_Setup.h -DBACKEND_URL=\"http://tu-servidor.com\"
-DWM_DEBUG_LEVEL=3 -DDEFAULT_DEVICE_TOKEN=\"tu-token-del-dispositivo\"
-DBACKEND_URL=\"https://dummybot.example.com\"
-DDEVICE_TOKEN=\"1|abc...xyz\"
build_src_filter = +<main.cpp> -<tests/>
``` ```
## 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 ## Pantalla OLED
- 06_surprised, 07_angry, 08_sleepy, 09_wink, 10_love
- 11_working, 12_excited
El firmware elige la emoción según: La OLED muestra:
- Sin tareas → `SLEEPY` / rotando demo - **Sin tareas**: "Sin tareas :)" + "Añade desde la web"
- Nueva tarea detectada → `EXCITED` + vibración - **Con tarea**: nombre de la tarea (en grande), prioridad en el header
- Prioridad `high``WORKING` - **Conectando**: "WiFi..." con puntos animados
- Hecha → `WINK` - **Portal cautivo**: "Config WiFi" + AP + contraseña
- Snooze → `WORRIED`
## 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 ## Tests
```bash ```bash
# Test de pantalla (colores + chip ID ST7789) # Test diagnóstico de pantalla (en otra rama)
pio run -e diag -t upload pio run -e diag -t upload
# Test de bus SPI (loopback MOSI↔MISO)
pio run -e spiloop -t upload
``` ```
## Licencia ## Licencia

View File

@ -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/<filename>.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)")

View File

@ -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

View File

@ -1,5 +1,5 @@
; PlatformIO Project Configuration File ; PlatformIO Project Configuration File
; DummyBot task manager ESP32 ; DummyBot v5 ESP32 + OLED SSD1306 128x64 (I2C) + 3 botones + vibrador
[env:esp32dev] [env:esp32dev]
platform = espressif32 @ 6.5.0 platform = espressif32 @ 6.5.0
@ -7,42 +7,20 @@ board = esp32dev
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_speed = 921600 upload_speed = 921600
board_build.partitions = partitions.csv
; Libs mínimas (sin GIFs ni WiFiManager)
lib_deps = 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 bblanchon/ArduinoJson@^7.0.4
build_flags = build_flags =
-DCORE_DEBUG_LEVEL=3 -DCORE_DEBUG_LEVEL=3
-DUSER_SETUP_LOADED=1 -DUSER_SETUP_LOADED=1
-include $PROJECT_DIR/include/User_Setup.h
; ─── CONFIG POR DEFECTO ─── ; ─── CONFIG POR DEFECTO ───
; Los defaults están en main.cpp (DEFAULT_WIFI_SSID, etc). -DDEFAULT_WIFI_SSID=\"BatKuevaUp\"
; Si quieres cambiarlos sin tocar código, define aquí las macros: -DDEFAULT_WIFI_PASS=\"JaMon2026\"
; -DDEFAULT_WIFI_SSID=\"otro\" -DDEFAULT_WIFI_PASS=\"otro\"
;
; Backend y token (sobreescribibles vía portal web)
-DBACKEND_URL=\"http://157.180.77.232:18080\" -DBACKEND_URL=\"http://157.180.77.232:18080\"
-DDEFAULT_DEVICE_TOKEN=\"tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU\" -DDEFAULT_DEVICE_TOKEN=\"UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c\"
build_src_filter = +<main.cpp> -<tests/> build_src_filter = +<main.cpp>
; ─────────────────────────────────────────────────────────────
; 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 = +<tests/diag.cpp>

View File

@ -1,37 +1,37 @@
// ============================================================ // ============================================================
// DummyBot firmware ESP32 v4 (TEXTO + Portal) // DummyBot firmware ESP32 v5
// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240) // OLED SSD1306 128x64 (I2C) + WiFi + 3 botones + vibrador
// - ** 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)
// ============================================================ // ============================================================
#include <Arduino.h> #include <Arduino.h>
#include <SPI.h> #include <Wire.h>
#include <TFT_eSPI.h> #include <Adafruit_GFX.h>
#include <SPIFFS.h> #include <Adafruit_SSD1306.h>
#include <WiFi.h> #include <WiFi.h>
#include <WebServer.h> #include <WebServer.h>
#include <DNSServer.h> #include <DNSServer.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <SPIFFS.h>
// -------------------- Pines -------------------- // -------------------- Pines --------------------
#define PIN_BTN_NEXT 26 #define PIN_BTN_NEXT 26
#define PIN_BTN_DONE 27 #define PIN_BTN_DONE 27
#define PIN_BTN_SNOOZE 14 #define PIN_BTN_SNOOZE 14
#define PIN_VIBRATOR 25 #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 -------------------- // -------------------- Config --------------------
#define POLL_MS 30000 #define POLL_MS 30000
#define VIB_BUZZ_MS 400 #define VIB_BUZZ_MS 400
#define VIB_PATTERN_MS 1500 #define VIB_PATTERN_MS 1500
#define SNOOZE_MINUTES 5 #define SNOOZE_MINUTES 5
#define API_TIMEOUT_MS 4000 #define API_TIMEOUT_MS 4000
const char* WIFI_AP_NAME = "DummyBot-Setup"; const char* WIFI_AP_NAME = "DummyBot-Setup";
const char* WIFI_AP_PASS = "dummybot"; const char* WIFI_AP_PASS = "dummybot";
@ -48,11 +48,11 @@ const char* WIFI_AP_PASS = "dummybot";
#endif #endif
#ifndef DEFAULT_DEVICE_TOKEN #ifndef DEFAULT_DEVICE_TOKEN
#define DEFAULT_DEVICE_TOKEN "tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU" #define DEFAULT_DEVICE_TOKEN "UrTQ0byozhEJxZQSC7LFmDq8liH3OnEBaFryUlU4yHaKcq2c"
#endif #endif
// -------------------- Estado global -------------------- // -------------------- Estado global --------------------
TFT_eSPI tft = TFT_eSPI(); Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, -1);
WebServer web(80); WebServer web(80);
DNSServer dnsServer; DNSServer dnsServer;
@ -84,6 +84,11 @@ char cfgBackend[128] = "";
char cfgName[40] = "Mi DummyBot"; char cfgName[40] = "Mi DummyBot";
bool portalActive = false; 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 -------------------- // -------------------- Vib --------------------
static void startVib() { static void startVib() {
vibActive = true; vibActive = true;
@ -103,94 +108,98 @@ static void updateVib() {
} }
// -------------------- UI -------------------- // -------------------- UI --------------------
static void drawHeader(const char *title) { static void drawCentered(const char *s, int y, int size = 1) {
tft.fillRect(0, 0, 240, 22, TFT_NAVY); int16_t x1, y1;
tft.setTextColor(TFT_WHITE, TFT_NAVY); uint16_t w, h;
tft.setTextSize(1); display.getTextBounds(s, 0, 0, &x1, &y1, &w, &h);
tft.setTextDatum(TL_DATUM); display.setCursor((SCREEN_W - w) / 2, y);
tft.drawString(title, 6, 6, 2); display.print(s);
}
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 drawIdleScreen() { static void drawIdleScreen() {
tft.fillScreen(TFT_BLACK); display.clearDisplay();
drawHeader("DummyBot"); display.setTextSize(1);
drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); display.setTextColor(SSD1306_WHITE);
tft.setTextColor(TFT_WHITE, TFT_BLACK); // Header
tft.setTextDatum(MC_DATUM); display.setCursor(0, 0);
tft.setTextSize(2); display.print(F("DummyBot"));
tft.drawString("Sin tareas :)", 120, 100, 4); // WiFi indicator
tft.setTextSize(1); if (WiFi.status() == WL_CONNECTED) {
tft.drawString("Anade desde la web", 120, 140, 2); display.print(F(" [W]"));
if (WiFi.status() != WL_CONNECTED) {
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
tft.drawString("WiFi: sin conexion", 120, 170, 2);
} else { } else {
tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); display.print(F(" [-]"));
tft.drawString(WiFi.localIP().toString().c_str(), 120, 170, 2);
} }
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() { static void drawTaskScreen() {
tft.fillScreen(TFT_BLACK); display.clearDisplay();
drawHeader("DummyBot - tarea"); display.setTextSize(1);
drawWifiIcon(220, 11, WiFi.status() == WL_CONNECTED); display.setTextColor(SSD1306_WHITE);
uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED : // Header con prioridad
(strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW; display.setCursor(0, 0);
tft.fillRect(0, 22, 240, 14, c); display.printf("DummyBot [%s]", currentTask.priority);
tft.setTextColor(TFT_BLACK, c); display.drawLine(0, 10, SCREEN_W - 1, 10, SSD1306_WHITE);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(1);
String prio = String("PRIORIDAD ") + currentTask.priority;
tft.drawString(prio, 120, 24, 2);
const char* emoji = "(._.)"; // Título (con wrap manual; 16 chars por línea a size 2)
if (strcmp(currentTask.priority, "high") == 0) emoji = "/(>_<)\\"; display.setTextSize(2);
else if (strcmp(currentTask.priority, "low") == 0) emoji = "^_^"; display.setCursor(0, 18);
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);
String title = String(currentTask.title); String title = String(currentTask.title);
int y = 130; if (title.length() > 16) {
int maxChars = 16; display.print(title.substring(0, 16));
int idx = 0; display.setCursor(0, 38);
while (idx < (int)title.length() && y < 200) { display.print(title.substring(16, 32));
tft.drawString(title.substring(idx, idx + maxChars), 8, y, 4); } else {
idx += maxChars; display.print(title);
y += 28;
} }
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) { static void drawWifiScreen(const char *msg) {
tft.fillScreen(TFT_BLACK); display.clearDisplay();
drawHeader("DummyBot - WiFi"); display.setTextSize(1);
tft.setTextColor(TFT_WHITE, TFT_BLACK); display.setTextColor(SSD1306_WHITE);
tft.setTextDatum(MC_DATUM); display.setCursor(0, 0);
tft.setTextSize(2); display.println(F("DummyBot - WiFi"));
tft.drawString(msg, 120, 80, 2); display.drawLine(0, 10, SCREEN_W - 1, 10, SSD1306_WHITE);
tft.setTextSize(1);
tft.drawString(WIFI_AP_NAME, 120, 115, 2); display.setCursor(0, 18);
tft.drawString(WIFI_AP_PASS, 120, 135, 2); display.println(msg);
drawFooter("conecta el movil para configurar");
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 -------------------- // -------------------- HTTP --------------------
@ -303,7 +312,7 @@ static void loadConfig() {
strncpy(cfgToken, doc["token"] | DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1); strncpy(cfgToken, doc["token"] | DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 1);
strncpy(cfgBackend, doc["backend"] | BACKEND_URL, sizeof(cfgBackend) - 1); strncpy(cfgBackend, doc["backend"] | BACKEND_URL, sizeof(cfgBackend) - 1);
strncpy(cfgName, doc["name"] | "Mi DummyBot", sizeof(cfgName) - 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(); f.close();
} }
@ -318,15 +327,79 @@ static void saveConfig() {
doc["name"] = cfgName; doc["name"] = cfgName;
serializeJson(doc, f); serializeJson(doc, f);
f.close(); f.close();
Serial.println("Config guardada en SPIFFS"); Serial.println("Config guardada");
} }
static void resetConfig() { static void resetConfig() {
if (SPIFFS.exists(WIFI_CFG_PATH)) SPIFFS.remove(WIFI_CFG_PATH); if (SPIFFS.exists(WIFI_CFG_PATH)) SPIFFS.remove(WIFI_CFG_PATH);
cfgSsid[0] = cfgPass[0] = cfgToken[0] = cfgBackend[0] = 0; cfgSsid[0] = cfgPass[0] = cfgToken[0] = cfgBackend[0] = 0;
} }
// -------------------- Portal -------------------- // -------------------- Captive portal --------------------
static const char PORTAL_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>DummyBot</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,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 rgba(255,255,255,0.08);border-radius:16px;padding:1.5rem;box-shadow:0 20px 60px rgba(0,0,0,0.5)}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 rgba(255,255,255,0.08);color:#f4f4f5;border-radius:8px;font-size:1rem;font-family:inherit}input:focus{outline:0;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,0.2)}button{margin-top:1rem;width:100%;padding:.65rem;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;border:none;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer}.help{font-size:.7rem;color:#71717a;margin-top:.2rem}.div{height:1px;background:rgba(255,255,255,0.08);margin:1rem 0}.emoji{font-size:2rem;margin-bottom:.3rem}</style></head><body><div class="c"><div class="card"><div class="emoji">🤖</div><h1>DummyBot</h1><p class="lead">Configura tu bot. Solo lo harás una vez.</p><form method="POST" action="/save"><label>Nombre del bot</label><input type="text" name="name" value="__NAME__" required maxlength="40"><label>Nombre de tu WiFi (SSID)</label><input type="text" name="ssid" value="__SSID__" required maxlength="32" autofocus><div class="help">Solo redes 2.4 GHz</div><label>Contraseña WiFi</label><input type="password" name="pass" value="__PASS__" maxlength="64"><label>URL del servidor</label><input type="url" name="backend" value="__BACKEND__" required><label>Token del dispositivo</label><input type="text" name="token" value="__TOKEN__" required maxlength="80"><div class="help">En la web: Ajustes → Dispositivos</div><button type="submit">💾 Guardar y conectar</button></form><div class="div"></div><form method="POST" action="/reset" onsubmit="return confirm('¿Borrar config?')"><button type="submit" style="background:transparent;border:1px solid rgba(239,68,68,0.4);color:#fca5a5">🗑 Borrar configuración</button></form></div></div></body></html>)====="; static const char PORTAL_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>DummyBot</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,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 rgba(255,255,255,0.08);border-radius:16px;padding:1.5rem;box-shadow:0 20px 60px rgba(0,0,0,0.5)}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 rgba(255,255,255,0.08);color:#f4f4f5;border-radius:8px;font-size:1rem;font-family:inherit}input:focus{outline:0;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,0.2)}button{margin-top:1rem;width:100%;padding:.65rem;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;border:none;border-radius:8px;font-weight:600;font-size:1rem;cursor:pointer}.help{font-size:.7rem;color:#71717a;margin-top:.2rem}.div{height:1px;background:rgba(255,255,255,0.08);margin:1rem 0}.emoji{font-size:2rem;margin-bottom:.3rem}.networks{margin-top:.3rem;max-height:160px;overflow-y:auto;border:1px solid rgba(255,255,255,0.06);border-radius:8px}.net{width:100%;text-align:left;padding:.6rem .7rem;background:transparent;border:none;border-bottom:1px solid rgba(255,255,255,0.04);color:#f4f4f5;cursor:pointer;font-size:.9rem;display:flex;justify-content:space-between;align-items:center}.net:hover{background:rgba(99,102,241,0.1)}.net:last-child{border-bottom:none}.net .rssi{font-size:.7rem;color:#71717a;font-family:monospace}.loading{padding:1rem;text-align:center;color:#71717a;font-size:.85rem}.spinner{display:inline-block;width:.8rem;height:.8rem;border:2px solid #6366f1;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;margin-right:.4rem;vertical-align:middle}@keyframes spin{to{transform:rotate(360deg)}}.row{display:flex;gap:.5rem}.row button{flex:1;margin-top:.3rem}.err{padding:.6rem .8rem;border-radius:6px;font-size:.8rem;margin:.5rem 0;background:rgba(239,68,68,0.1);color:#fca5a5;border:1px solid rgba(239,68,68,0.3)}.ok{padding:.6rem .8rem;border-radius:6px;font-size:.8rem;margin:.5rem 0;background:rgba(34,197,94,0.1);color:#86efac;border:1px solid rgba(34,197,94,0.3)}#test{padding:.5rem;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06);color:#f4f4f5;border-radius:8px;font-size:.9rem;cursor:pointer}#test:hover{background:rgba(99,102,241,0.15)}</style></head><body><div class="c"><div class="card"><div class="emoji">🤖</div><h1>DummyBot</h1><p class="lead">Configura tu bot. Solo lo harás una vez.</p><div id="result"></div><form id="cfgform"><label>Nombre</label><input type="text" name="name" value="__NAME__" required><label>Red WiFi (toca una para seleccionar)</label><div class="networks" id="nets"><div class="loading"><span class="spinner"></span>Buscando redes...</div></div><div class="help">Si no ves tu red, escríbela abajo</div><input type="text" name="ssid" id="ssid" value="__SSID__" required placeholder="O escribe el SSID manualmente"><label>Contraseña WiFi</label><input type="password" name="pass" id="pass" value="__PASS__"><div class="row"><button type="button" id="test" onclick="testConn()">🔍 Probar conexión</button></div><label>Backend URL</label><input type="url" name="backend" value="__BACKEND__" required><label>Token</label><input type="text" name="token" value="__TOKEN__" required><div class="help">Ajustes → Dispositivos</div><button type="button" onclick="saveCfg()">💾 Guardar y conectar</button></form><div class="div"></div><form method="POST" action="/reset" onsubmit="return confirm('¿Borrar config?')"><button type="submit" style="background:transparent;border:1px solid rgba(239,68,68,0.4);color:#fca5a5">🗑 Reset</button></form></div></div><script>
let picked = null;
function bars(rssi){const dBm=Math.max(0,Math.min(4,Math.floor((rssi+100)/10)));let s='';for(let i=0;i<4;i++)s+= i<dBm?'':' ';return s}
function load(){fetch('/networks').then(r=>r.json()).then(networks=>{
const el=document.getElementById('nets');
if(!networks.length){el.innerHTML='<div class="loading">No se encontraron redes</div>';return}
el.innerHTML='';
networks.sort((a,b)=>b.rssi-a.rssi);
networks.forEach(n=>{
const btn=document.createElement('button');
btn.type='button';
btn.className='net';
btn.dataset.ssid=n.ssid;
btn.onclick=function(e){selectNet(n,e)};
btn.innerHTML='<span>'+n.ssid+(n.enc?' 🔒':'')+'</span><span class="rssi">'+bars(n.rssi)+' '+n.rssi+'dBm</span>';
el.appendChild(btn);
});
}).catch(()=>{document.getElementById('nets').innerHTML='<div class="loading">Error al escanear</div>';})}
function selectNet(n,e){picked=n.ssid;document.getElementById('ssid').value=n.ssid;document.querySelectorAll('.net').forEach(b=>b.style.background='');e.currentTarget.style.background='rgba(99,102,241,0.25)'}
async function testConn(){
const ssid=document.getElementById('ssid').value;
const pass=document.getElementById('pass').value;
const res=document.getElementById('result');
res.innerHTML='<div class="loading"><span class="spinner"></span>Probando...</div>';
try{
const r=await fetch('/test?ssid='+encodeURIComponent(ssid)+'&pass='+encodeURIComponent(pass));
const j=await r.json();
if(j.ok){
res.innerHTML='<div class="ok">✓ Conexión OK a "'+j.ssid+'" ('+j.ip+', '+j.rssi+' dBm)</div>';
} else {
let msg=' Falló: ';
switch(j.reason){
case 'NO_SSID_AVAIL': msg+='red no encontrada'; break;
case 'WRONG_PASSWORD': msg+='contraseña incorrecta'; break;
case 'NO_IP': msg+='conectó pero sin IP'; break;
case 'TIMEOUT': msg+='tardó demasiado'; break;
default: msg+=j.reason;
}
res.innerHTML='<div class="err">'+msg+'</div>';
}
} catch(e){
res.innerHTML='<div class="err">Error: '+e.message+'</div>';
}
}
async function saveCfg(){
const ssid=document.getElementById('ssid').value;
const pass=document.getElementById('pass').value;
const form=document.getElementById('cfgform');
const data=new FormData(form);
data.set('ssid',ssid); data.set('pass',pass);
const res=document.getElementById('result');
res.innerHTML='<div class="loading"><span class="spinner"></span>Guardando...</div>';
try{
const r=await fetch('/save',{method:'POST',body:new URLSearchParams(data)});
if(r.ok){
res.innerHTML='<div class="ok">✓ Guardado. El bot se está reiniciando...</div>';
} else {
res.innerHTML='<div class="err">Error guardando</div>';
}
} catch(e){
res.innerHTML='<div class="err">Error: '+e.message+'</div>';
}
}
load();setInterval(load,15000);
</script></body></html>)=====";
static String renderPortal() { static String renderPortal() {
String html = FPSTR(PORTAL_HTML); String html = FPSTR(PORTAL_HTML);
@ -378,14 +451,81 @@ static void handleReset() {
ESP.restart(); 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() { static void startPortal() {
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
WiFi.softAP(WIFI_AP_NAME, WIFI_AP_PASS); WiFi.softAP(WIFI_AP_NAME, WIFI_AP_PASS);
Serial.printf("AP abierto: %s\n", WIFI_AP_NAME); Serial.printf("AP abierto: %s\n", WIFI_AP_NAME);
dnsServer.start(53, "*", WiFi.softAPIP()); dnsServer.start(53, "*", WiFi.softAPIP());
web.on("/", HTTP_GET, handleRoot); web.on("/", HTTP_GET, handleRoot);
web.on("/save", HTTP_POST, handleSave); web.on("/networks", HTTP_GET, handleNetworks);
web.on("/reset", HTTP_POST, handleReset); web.on("/test", HTTP_GET, handleTest);
web.on("/save", HTTP_POST, handleSave);
web.on("/reset", HTTP_POST, handleReset);
web.onNotFound(handleCaptive); web.onNotFound(handleCaptive);
web.begin(); web.begin();
portalActive = true; portalActive = true;
@ -413,26 +553,36 @@ static void startSTA() {
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(500); delay(500);
Serial.println("\n=== DummyBot v4 ==="); Serial.println("\n=== DummyBot v5 (OLED) ===");
pinMode(PIN_BTN_NEXT, INPUT); pinMode(PIN_BTN_NEXT, INPUT);
pinMode(PIN_BTN_DONE, INPUT); pinMode(PIN_BTN_DONE, INPUT);
pinMode(PIN_BTN_SNOOZE, INPUT); pinMode(PIN_BTN_SNOOZE, INPUT);
pinMode(PIN_VIBRATOR, OUTPUT); pinMode(PIN_VIBRATOR, OUTPUT);
digitalWrite(PIN_VIBRATOR, LOW); digitalWrite(PIN_VIBRATOR, LOW);
pinMode(PIN_TFT_BL, OUTPUT);
digitalWrite(PIN_TFT_BL, HIGH);
Serial.println("Init TFT..."); // I2C + OLED
tft.init(); Wire.begin(SDA_PIN, SCL_PIN);
tft.setRotation(0); if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println("TFT OK"); Serial.println("OLED FAIL");
while (1) delay(1000);
}
Serial.println("OLED OK");
if (!SPIFFS.begin(true)) Serial.println("SPIFFS ERROR"); if (!SPIFFS.begin(true)) Serial.println("SPIFFS ERROR");
loadConfig(); loadConfig();
if (!cfgBackend[0]) strncpy(cfgBackend, BACKEND_URL, sizeof(cfgBackend) - 1); if (!cfgBackend[0]) strncpy(cfgBackend, BACKEND_URL, sizeof(cfgBackend) - 1);
if (!cfgToken[0]) strncpy(cfgToken, DEFAULT_DEVICE_TOKEN, sizeof(cfgToken) - 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 // Reset con BOOT 5s
pinMode(0, INPUT_PULLUP); pinMode(0, INPUT_PULLUP);
if (digitalRead(0) == LOW) { if (digitalRead(0) == LOW) {
@ -444,27 +594,15 @@ void setup() {
} }
} }
// Pantalla de bienvenida wifiState = W_CONNECTING;
tft.fillScreen(TFT_BLACK); wifiStart = millis();
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);
startSTA(); startSTA();
Serial.println("Setup OK");
} }
enum RunMode { MODE_CONNECTING, MODE_PORTAL, MODE_RUNNING };
RunMode mode = MODE_CONNECTING;
uint32_t modeStart = 0;
void loop() { void loop() {
updateVib(); updateVib();
if (mode == MODE_PORTAL) { if (wifiState == W_PORTAL) {
dnsServer.processNextRequest(); dnsServer.processNextRequest();
web.handleClient(); web.handleClient();
handleButtons(); handleButtons();
@ -483,38 +621,39 @@ void loop() {
uint32_t now = millis(); uint32_t now = millis();
if (mode == MODE_CONNECTING) { if (wifiState == W_CONNECTING) {
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
mode = MODE_RUNNING; wifiState = W_CONNECTED;
modeStart = now;
Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str()); Serial.printf("WiFi OK: %s\n", WiFi.localIP().toString().c_str());
drawIdleScreen();
lastPoll = now; lastPoll = now;
pollTasks(); pollTasks();
} else if (now - modeStart > 25000) { } else if (now - wifiStart > 25000) {
Serial.println("WiFi falló, abriendo portal..."); Serial.println("WiFi falló, abriendo portal...");
startPortal(); startPortal();
mode = MODE_PORTAL; wifiState = W_PORTAL;
} else if (now - modeStart > 1000) { } else if (now - wifiStart > 500) {
static int dots = 0; static int dots = 0;
dots = (dots + 1) % 4; dots = (dots + 1) % 4;
tft.fillScreen(TFT_BLACK); display.clearDisplay();
tft.setTextColor(TFT_WHITE, TFT_BLACK); display.setTextSize(2);
tft.setTextDatum(MC_DATUM); display.setTextColor(SSD1306_WHITE);
tft.setTextSize(2); drawCentered("WiFi...", 16, 2);
display.setTextSize(1);
char msg[20]; char msg[20];
snprintf(msg, sizeof(msg), "WiFi %.*s", dots, "..."); snprintf(msg, sizeof(msg), "%.*s", dots, "...");
tft.drawString(msg, 120, 100, 2); display.setCursor(0, 50);
tft.setTextSize(1); display.print(msg);
tft.drawString(cfgSsid, 120, 140, 2); display.print(F(" "));
display.println(cfgSsid);
display.display();
} }
return; return;
} }
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi perdido, reintentando..."); Serial.println("WiFi perdido");
mode = MODE_CONNECTING; wifiState = W_CONNECTING;
modeStart = now; wifiStart = now;
startSTA(); startSTA();
return; return;
} }

View File

@ -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 <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
// 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);
}

View File

@ -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 <Arduino.h>
#include <SPI.h>
#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);
}