v2: TFT_eSPI + SPIFFS + AnimatedGIF, 12 caritas kawaii, polling 30s, vibrador, 3 botones

This commit is contained in:
javiservices 2026-06-17 02:07:52 +02:00
commit 393a72f6e0
8 changed files with 1228 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.pio/
.vscode/
*.bin
data/
gifs/

437
gen_gifs.py Normal file
View File

@ -0,0 +1,437 @@
#!/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)")

47
include/User_Setup.h Normal file
View File

@ -0,0 +1,47 @@
// ============================================================
// 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 2 // Reset
#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

8
partitions.csv Normal file
View File

@ -0,0 +1,8 @@
# DummyBot partition table
# 4MB flash ESP32-WROOM-32
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x5000
otadata, data, ota, 0xe000, 0x2000
app0, app, ota_0, 0x10000, 0x180000
app1, app, ota_1, 0x190000, 0x180000
spiffs, data, spiffs, 0x310000, 0xCF000
1 # DummyBot partition table
2 # 4MB flash ESP32-WROOM-32
3 # Name, Type, SubType, Offset, Size
4 nvs, data, nvs, 0x9000, 0x5000
5 otadata, data, ota, 0xe000, 0x2000
6 app0, app, ota_0, 0x10000, 0x180000
7 app1, app, ota_1, 0x190000, 0x180000
8 spiffs, data, spiffs, 0x310000, 0xCF000

61
platformio.ini Normal file
View File

@ -0,0 +1,61 @@
; PlatformIO Project Configuration File
; DummyBot task manager for ESP32 + ST7789 240x240 + 3x TTP223 + vibrador
; Firmware: TFT_eSPI (texto) + SPIFFS + AnimatedGIF (caritas kawaii)
; Backend: Laravel (HTTP polling cada 30s)
[env:esp32dev]
platform = espressif32 @ 6.5.0
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
board_build.partitions = partitions.csv
; Libs
lib_deps =
bodmer/TFT_eSPI@^2.5.43
bblanchon/ArduinoJson@^7.0.4
tzapu/WiFiManager@^2.0.17
bitbank2/AnimatedGIF@^1.4.1
; User config del display (TFT_eSPI) y WiFi
build_flags =
-DCORE_DEBUG_LEVEL=3
-DUSER_SETUP_LOADED=1
-include $PROJECT_DIR/include/User_Setup.h
-DWM_DEBUG_LEVEL=3
-DBACKEND_URL=\"http://157.180.77.232:18080\"
-DDEVICE_TOKEN=\"tDL6yCqvVetJpiY8dMyi7QwCW2znO6RnRN5hVeM7H8u0LbjU\"
; Para apuntar a tu servidor, sobreescribe BACKEND_URL en una env:
; [env:mi-server]
; build_flags = ${env:esp32dev.build_flags} -DBACKEND_URL=\"https://midominio.com\"
build_src_filter = +<main.cpp> -<tests/>
; ─────────────────────────────────────────────────────────────
; Entorno de DIAGNÓSTICO: solo compila diag.cpp (sin main.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>
; ─────────────────────────────────────────────────────────────
; Test SPI loopback (para verificar el bus SPI del ESP32)
; ─────────────────────────────────────────────────────────────
[env:spiloop]
platform = espressif32 @ 6.5.0
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 921600
lib_deps =
build_src_filter = +<tests/spi_loopback.cpp>

482
src/main.cpp Normal file
View File

@ -0,0 +1,482 @@
// ============================================================
// DummyBot firmware ESP32 v2 (TFT_eSPI + SPIFFS + AnimatedGIF)
// - Pantalla M130T-240240-RGB-7-V1.1 (ST7789 240x240 SPI)
// - 3x TTP223 (Siguiente / Hecho / Snooze)
// - 1x vibrador (GPIO 25 con transistor)
// - 12 GIFs animados de caritas en SPIFFS (firmware/gifs/)
// ============================================================
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <SPIFFS.h>
#include <AnimatedGIF.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFiManager.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
// -------------------- 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 FACE_SIZE 80
const char* WIFI_AP_NAME = "DummyBot-Setup";
const char* WIFI_AP_PASS = "dummybot";
#ifndef BACKEND_URL
#define BACKEND_URL "http://157.180.77.232:18080"
#endif
// Token del dispositivo (lo genera la web). Si está vacío, no se autentica.
#ifndef DEVICE_TOKEN
#define DEVICE_TOKEN ""
#endif
// -------------------- Estado global --------------------
TFT_eSPI tft = TFT_eSPI();
AnimatedGIF gif;
// Cara actual
enum Emotion {
EMO_NEUTRAL = 1, EMO_HAPPY, EMO_WORRIED, EMO_SAD, EMO_CRY,
EMO_SURPRISED, EMO_ANGRY, EMO_SLEEPY, EMO_WINK, EMO_LOVE,
EMO_WORKING, EMO_EXCITED
};
struct Task {
int id;
char title[64];
char priority[8];
int next_due_in;
};
Task currentTask;
bool hasTask = false;
uint32_t lastPoll = 0;
uint32_t lastSeenId = 0;
Emotion currentEmotion = EMO_NEUTRAL;
char currentGifName[32];
// GIF rendering
int16_t gif_x = 0, gif_y = 0;
bool gifOpen = false;
int lastGifDelay = 0;
uint32_t nextGifMs = 0;
// Vibrador
bool vibActive = false;
uint32_t vibStart = 0;
// Botones
uint32_t lastBtnMs[3] = {0,0,0};
const uint8_t BTN_PINS[3] = { PIN_BTN_NEXT, PIN_BTN_DONE, PIN_BTN_SNOOZE };
#define DEBOUNCE_MS 200
// -------------------- GIF callbacks --------------------
static void *gif_open_cb(const char *fname, int32_t *pSize) {
fs::File *f = new fs::File(SPIFFS.open(fname, "r"));
if (!*f) { delete f; return nullptr; }
*pSize = f->size();
return f;
}
static void gif_close_cb(void *handle) {
if (!handle) return;
fs::File *f = (fs::File *)handle;
f->close();
delete f;
}
static int32_t gif_read_cb(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
fs::File *f = (fs::File *)pFile->fHandle;
return f->read(pBuf, iLen);
}
static int32_t gif_seek_cb(GIFFILE *pFile, int32_t iPos) {
fs::File *f = (fs::File *)pFile->fHandle;
return f->seek(iPos) ? 1 : 0;
}
static void gif_draw_callback(GIFDRAW *pDraw) {
uint16_t *pixels = (uint16_t *)pDraw->pPixels;
int16_t x = pDraw->iX + gif_x;
int16_t y = pDraw->iY + gif_y;
int16_t w = pDraw->iWidth;
if (pDraw->iY == 0) {
tft.startWrite();
tft.setAddrWindow(x, y, w, 1);
}
tft.pushColors(pixels, w, false);
if (pDraw->iY == pDraw->iHeight - 1) {
tft.endWrite();
}
}
// -------------------- Vib & Tareas --------------------
static void startVib() {
vibActive = true;
vibStart = millis();
digitalWrite(PIN_VIBRATOR, HIGH);
}
static void stopVib() {
vibActive = false;
digitalWrite(PIN_VIBRATOR, LOW);
}
static void updateVib() {
if (!vibActive) return;
uint32_t e = millis() - vibStart;
if (e >= VIB_PATTERN_MS) { stopVib(); return; }
uint32_t phase = e % (VIB_BUZZ_MS + 200);
digitalWrite(PIN_VIBRATOR, phase < VIB_BUZZ_MS ? HIGH : LOW);
}
// -------------------- UI --------------------
static void emotionToGif(Emotion e, char *out, size_t sz) {
const char *name = "01_neutral.gif";
switch (e) {
case EMO_NEUTRAL: name = "01_neutral.gif"; break;
case EMO_HAPPY: name = "02_happy.gif"; break;
case EMO_WORRIED: name = "03_worried.gif"; break;
case EMO_SAD: name = "04_sad.gif"; break;
case EMO_CRY: name = "05_cry.gif"; break;
case EMO_SURPRISED: name = "06_surprised.gif"; break;
case EMO_ANGRY: name = "07_angry.gif"; break;
case EMO_SLEEPY: name = "08_sleepy.gif"; break;
case EMO_WINK: name = "09_wink.gif"; break;
case EMO_LOVE: name = "10_love.gif"; break;
case EMO_WORKING: name = "11_working.gif"; break;
case EMO_EXCITED: name = "12_excited.gif"; break;
}
strncpy(out, name, sz - 1);
out[sz - 1] = '\0';
}
// Mapeo emoción según prioridad
static Emotion emotionForTask(const Task *t) {
if (!t) return EMO_NEUTRAL;
if (strcmp(t->priority, "high") == 0) return EMO_WORKING;
if (strcmp(t->priority, "low") == 0) return EMO_HAPPY;
return EMO_NEUTRAL;
}
static void drawHeader() {
tft.fillRect(0, 0, 240, 22, TFT_NAVY);
tft.setTextColor(TFT_WHITE, TFT_NAVY);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.drawString("DummyBot - tareas", 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 drawIdle() {
tft.fillScreen(TFT_BLACK);
drawHeader();
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);
drawFooter("Snooze | Hecha | Siguiente");
}
static void drawTaskScreen() {
tft.fillScreen(TFT_BLACK);
drawHeader();
// Banda de prioridad
uint16_t c = (strcmp(currentTask.priority, "high") == 0) ? TFT_RED :
(strcmp(currentTask.priority, "low") == 0) ? TFT_GREEN : TFT_YELLOW;
tft.fillRect(0, 22, 240, 12, 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);
// Título (debajo de la cara que está en la esquina)
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.setTextDatum(TL_DATUM);
String title = String(currentTask.title);
int y = 110;
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 (currentTask.next_due_in > 0) {
tft.setTextSize(1);
tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
int mins = currentTask.next_due_in / 60;
String due = "Vence en ";
if (mins >= 60) {
due += String(mins / 60) + "h " + String(mins % 60) + "m";
} else if (mins > 0) {
due += String(mins) + " min";
} else {
due += "ahora";
}
tft.drawString(due, 8, 195, 2);
}
drawFooter("Snooze | Hecha | Siguiente");
}
// -------------------- GIF control --------------------
static void setEmotion(Emotion e) {
if (e == currentEmotion && gifOpen) return;
char name[32];
emotionToGif(e, name, sizeof(name));
if (strcmp(name, currentGifName) == 0 && gifOpen) return;
if (gifOpen) {
gif.close();
gifOpen = false;
}
strncpy(currentGifName, name, sizeof(currentGifName) - 1);
char path[40];
snprintf(path, sizeof(path), "/gifs/%s", currentGifName);
gif_x = 240 - FACE_SIZE - 4;
gif_y = 28;
if (SPIFFS.exists(path)) {
tft.fillRect(gif_x, gif_y, FACE_SIZE, FACE_SIZE, TFT_WHITE);
if (gif.open(path, gif_open_cb, gif_close_cb,
gif_read_cb, gif_seek_cb, gif_draw_callback)) {
Serial.printf("GIF abierto: %s\n", path);
gifOpen = true;
currentEmotion = e;
nextGifMs = millis();
} else {
Serial.printf("GIF no se pudo abrir: %s\n", path);
}
} else {
Serial.printf("No se encuentra %s en SPIFFS\n", path);
}
}
static void updateGif() {
if (!gifOpen) return;
if ((int32_t)(millis() - nextGifMs) >= 0) {
int delay_ms = 0;
if (gif.playFrame(false, &delay_ms) == -1) {
gif.reset();
return;
}
nextGifMs = millis() + (delay_ms > 0 ? delay_ms : 100);
}
}
// -------------------- HTTP --------------------
static bool httpGetJson(const char *url, JsonDocument &doc) {
if (WiFi.status() != WL_CONNECTED) return false;
HTTPClient http;
http.begin(url);
http.setTimeout(API_TIMEOUT_MS);
if (strlen(DEVICE_TOKEN) > 0) {
http.addHeader("Authorization", "Bearer " DEVICE_TOKEN);
}
int code = http.GET();
if (code != 200) { http.end(); return false; }
DeserializationError err = deserializeJson(doc, http.getStream());
http.end();
return !err;
}
static bool httpPostJson(const char *url, const char *body) {
if (WiFi.status() != WL_CONNECTED) return false;
HTTPClient http;
http.begin(url);
http.setTimeout(API_TIMEOUT_MS);
http.addHeader("Content-Type", "application/json");
if (strlen(DEVICE_TOKEN) > 0) {
http.addHeader("Authorization", "Bearer " DEVICE_TOKEN);
}
int code = http.POST(body);
http.end();
return code >= 200 && code < 300;
}
// -------------------- Polling / Acciones --------------------
static void pollTasks() {
char url[160];
snprintf(url, sizeof(url), "%s/api/device/tasks/pending", BACKEND_URL);
JsonDocument doc;
if (!httpGetJson(url, doc)) return;
JsonArray arr = doc.as<JsonArray>();
if (arr.size() == 0) {
hasTask = false;
if (currentEmotion != EMO_SLEEPY) setEmotion(EMO_SLEEPY);
drawIdle();
return;
}
JsonObject first = arr[0];
Task t;
t.id = first["id"] | 0;
strncpy(t.title, first["title"] | "", sizeof(t.title) - 1);
strncpy(t.priority, first["priority"] | "med", sizeof(t.priority) - 1);
t.next_due_in = first["next_due_in"] | 0;
if ((uint32_t)t.id != lastSeenId) {
if (lastSeenId != 0) startVib();
lastSeenId = (uint32_t)t.id;
}
currentTask = t;
hasTask = true;
drawTaskScreen();
setEmotion(emotionForTask(&t));
}
static void actionDone() {
if (!hasTask) return;
char url[160], body[8];
snprintf(url, sizeof(url), "%s/api/device/tasks/%d/done", BACKEND_URL, currentTask.id);
snprintf(body, sizeof(body), "{}");
if (httpPostJson(url, body)) {
lastSeenId = 0;
setEmotion(EMO_WINK);
delay(800);
pollTasks();
}
}
static void actionSnooze() {
if (!hasTask) return;
char url[160], body[40];
snprintf(url, sizeof(url), "%s/api/device/tasks/%d/snooze", BACKEND_URL, currentTask.id);
snprintf(body, sizeof(body), "{\"minutes\":%d}", SNOOZE_MINUTES);
if (httpPostJson(url, body)) {
lastSeenId = 0;
setEmotion(EMO_WORRIED);
delay(800);
pollTasks();
}
}
static void actionNext() { pollTasks(); }
// -------------------- Botones --------------------
static void handleButtons() {
uint32_t now = millis();
for (int i = 0; i < 3; i++) {
if (digitalRead(BTN_PINS[i]) == HIGH && (now - lastBtnMs[i] > DEBOUNCE_MS)) {
lastBtnMs[i] = now;
switch (i) {
case 0: actionNext(); break;
case 1: actionDone(); break;
case 2: actionSnooze(); break;
}
}
}
}
// -------------------- Setup / Loop --------------------
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n=== DummyBot v2 arrancando ===");
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);
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
// SPIFFS
if (!SPIFFS.begin(true)) {
Serial.println("ERROR: SPIFFS mount failed");
} else {
Serial.printf("SPIFFS OK, %u bytes usados / %u totales\n",
SPIFFS.usedBytes(), SPIFFS.totalBytes());
}
drawIdle();
// Modo demo controlado por una constante de compile
#ifdef DUMMYBOT_DEMO
Serial.println("DUMMYBOT_DEMO: saltamos WiFi, modo demo de emociones");
setEmotion(EMO_HAPPY);
lastPoll = millis() + 60000; // no pollear
#else
// Mensaje "configurando WiFi" en pantalla
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(2);
tft.drawString("WiFi: conecta", 120, 90, 2);
tft.setTextSize(1);
tft.drawString("AP: DummyBot-Setup", 120, 130, 2);
tft.drawString("pass: dummybot", 120, 145, 2);
drawFooter("192.168.4.1");
WiFiManager wm;
wm.setConfigPortalTimeout(60);
wm.setAPCallback([](WiFiManager *) {
Serial.printf("AP abierto DummyBot-Setup, IP: %s\n",
WiFi.softAPIP().toString().c_str());
});
if (!wm.autoConnect(WIFI_AP_NAME, WIFI_AP_PASS)) {
Serial.println("WiFi falló -> seguimos sin red");
setEmotion(EMO_WORRIED);
} else {
Serial.printf("WiFi OK, IP: %s\n", WiFi.localIP().toString().c_str());
setEmotion(EMO_HAPPY);
pollTasks();
}
lastPoll = millis();
#endif
}
void loop() {
handleButtons();
updateVib();
updateGif();
uint32_t now = millis();
// Demo: rotar emociones cada 3 s si no hay tarea real
if (!hasTask) {
static uint32_t lastDemo = 0;
static int demo_idx = 0;
const Emotion demo[] = { EMO_HAPPY, EMO_SURPRISED, EMO_WORRIED, EMO_SLEEPY,
EMO_LOVE, EMO_ANGRY, EMO_CRY, EMO_EXCITED };
if (now - lastDemo > 3000) {
lastDemo = now;
demo_idx = (demo_idx + 1) % 8;
setEmotion(demo[demo_idx]);
}
}
if (now - lastPoll >= POLL_MS) {
lastPoll = now;
pollTasks();
}
delay(20);
}

114
src/tests/diag.cpp Normal file
View File

@ -0,0 +1,114 @@
// ============================================================
// 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

@ -0,0 +1,74 @@
// ============================================================
// 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);
}