v5: OLED SSD1306 128x64 (I2C), captive portal con WiFi scanner y test de conexión
This commit is contained in:
parent
fe08f175e7
commit
9fb87a4d62
132
README.md
132
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 = +<main.cpp> -<tests/>
|
||||
-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
|
||||
437
gen_gifs.py
437
gen_gifs.py
@ -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)")
|
||||
@ -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
|
||||
@ -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 = +<main.cpp> -<tests/>
|
||||
|
||||
; ─────────────────────────────────────────────────────────────
|
||||
; 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>
|
||||
build_src_filter = +<main.cpp>
|
||||
413
src/main.cpp
413
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 <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <SPIFFS.h>
|
||||
#include <Wire.h>
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_SSD1306.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <SPIFFS.h>
|
||||
|
||||
// -------------------- 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"=====(<!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>)=====";
|
||||
// -------------------- 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}.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() {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user