commit 393a72f6e0dec878152935ca40edb591fa69806d Author: javiservices Date: Wed Jun 17 02:07:52 2026 +0200 v2: TFT_eSPI + SPIFFS + AnimatedGIF, 12 caritas kawaii, polling 30s, vibrador, 3 botones diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fa4eca --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio/ +.vscode/ +*.bin +data/ +gifs/ diff --git a/gen_gifs.py b/gen_gifs.py new file mode 100644 index 0000000..03007e9 --- /dev/null +++ b/gen_gifs.py @@ -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/.gif y se suben a SPIFFS del ESP32. +""" + +import os +from PIL import Image, ImageDraw + +SIZE = 80 +OUT_DIR = os.path.join(os.path.dirname(__file__), "gifs") +os.makedirs(OUT_DIR, exist_ok=True) + +# Paleta kawaii +SKIN = (255, 230, 200, 255) # piel +WHITE = (255, 255, 255, 255) +BLACK = (20, 20, 30, 255) +PINK = (255, 150, 180, 255) # blush +LBLUE = (180, 215, 255, 255) # lágrima +RED = (220, 70, 90, 255) +GREEN = (110, 200, 130, 255) +YELLOW = (250, 200, 60, 255) +PURPLE = (180, 130, 230, 255) + + +def new_canvas(): + return Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + + +def draw_face(draw, *, eye_y=40, eye_lx=24, eye_rx=56, + eye_w=12, eye_h=14, pupil_dx=0, pupil_dy=0, + brow_l=(20, 20, 32, 20), brow_r=(48, 20, 60, 20), + mouth_kind="neutral", open_pct=100, blush=True, + extras=None): + """Dibuja la cara con los parámetros dados. Devuelve la imagen.""" + # Borde de la cara (círculo) + draw.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) + + # Blush + if blush: + draw.ellipse([(10, 48), (24, 56)], fill=PINK) + draw.ellipse([(56, 48), (70, 56)], fill=PINK) + + # Cejas + draw.line([(brow_l[0], brow_l[1]), (brow_l[2], brow_l[3])], fill=BLACK, width=3) + draw.line([(brow_r[0], brow_r[1]), (brow_r[2], brow_r[3])], fill=BLACK, width=3) + + # Ojos + open_h = max(2, int(eye_h * open_pct / 100)) + # izquierdo + draw.ellipse([(eye_lx - eye_w // 2, eye_y - open_h // 2 + pupil_dy), + (eye_lx + eye_w // 2, eye_y + open_h // 2 + pupil_dy)], + fill=WHITE, outline=BLACK, width=2) + # derecho + draw.ellipse([(eye_rx - eye_w // 2, eye_y - open_h // 2 + pupil_dy), + (eye_rx + eye_w // 2, eye_y + open_h // 2 + pupil_dy)], + fill=WHITE, outline=BLACK, width=2) + # pupilas (solo si el ojo está abierto) + if open_pct > 30: + pdx = 3 if pupil_dx > 0 else (-3 if pupil_dx < 0 else 0) + pdy = 3 if pupil_dy > 0 else (-3 if pupil_dy < 0 else 0) + draw.ellipse([(eye_lx - 2 + pdx, eye_y - 2 + pdy), + (eye_lx + 2 + pdx, eye_y + 2 + pdy)], fill=BLACK) + draw.ellipse([(eye_rx - 2 + pdx, eye_y - 2 + pdy), + (eye_rx + 2 + pdx, eye_y + 2 + pdy)], fill=BLACK) + + # Boca + if mouth_kind == "neutral": + draw.line([(34, 60), (46, 60)], fill=BLACK, width=2) + elif mouth_kind == "happy": + draw.arc([(28, 54), (52, 70)], 10, 170, fill=BLACK, width=3) + elif mouth_kind == "open_smile": + draw.ellipse([(30, 54), (50, 70)], fill=RED, outline=BLACK, width=2) + elif mouth_kind == "sad": + draw.arc([(28, 60), (52, 76)], 190, 350, fill=BLACK, width=3) + elif mouth_kind == "frown": + draw.line([(30, 70), (50, 60)], fill=BLACK, width=2) + elif mouth_kind == "kiss": + draw.ellipse([(34, 60), (46, 68)], fill=RED, outline=BLACK, width=1) + elif mouth_kind == "diamond": + # boca de sorpresa + draw.polygon([(40, 54), (48, 62), (40, 70), (32, 62)], + fill=RED, outline=BLACK) + elif mouth_kind == "teeth": + # boca apretada + draw.rectangle([(30, 60), (50, 65)], fill=WHITE, outline=BLACK, width=1) + for x in range(34, 50, 4): + draw.line([(x, 60), (x, 65)], fill=BLACK) + elif mouth_kind == "tongue": + draw.ellipse([(30, 56), (50, 68)], fill=RED, outline=BLACK, width=2) + draw.ellipse([(36, 62), (44, 70)], fill=PINK, outline=BLACK, width=1) + elif mouth_kind == "zigzag": + # sonrisa traviesa + for x in range(30, 52, 4): + draw.line([(x, 60), (x + 3, 67)], fill=BLACK, width=2) + for x in range(30, 52, 4): + draw.line([(x + 3, 67), (x + 6, 60)], fill=BLACK, width=2) + + if extras: + extras(draw) + + +def save_anim(frames, filename, duration=80, loop=0): + """Guarda una secuencia de frames como GIF animado.""" + # Convertir a 'P' mode con paleta transparente + out = [] + for f in frames: + # Asegurar RGBA + out.append(f.convert("RGBA")) + out_path = os.path.join(OUT_DIR, filename) + out[0].save(out_path, save_all=True, append_images=out[1:], + duration=duration, loop=loop, transparency=0, + disposal=2, optimize=True) + print(f" -> {filename}: {os.path.getsize(out_path)} bytes, {len(out)} frames") + + +# ─────────────────── 1. NEUTRAL ─────────────────── +def gen_neutral(): + print("Generando 01_neutral.gif") + frames = [] + for i in range(8): + img = new_canvas() + d = ImageDraw.Draw(img) + pupil_dx = (1 if i % 2 == 0 else -1) # mirada lateral + draw_face(d, eye_y=40, pupil_dx=pupil_dx, mouth_kind="neutral", + brow_l=(28, 18, 32, 18), brow_r=(48, 18, 52, 18), + open_pct=100, blush=True) + frames.append(img) + save_anim(frames, "01_neutral.gif", duration=120) + + +# ─────────────────── 2. HAPPY ─────────────────── +def gen_happy(): + print("Generando 02_happy.gif") + frames = [] + for i in range(10): + # bounce up/down + offset = -1 if i % 2 == 0 else 1 + img = new_canvas() + d = ImageDraw.Draw(img) + d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], + fill=SKIN, outline=BLACK, width=2) + # blush + d.ellipse([(10, 48 + offset), (24, 56 + offset)], fill=PINK) + d.ellipse([(56, 48 + offset), (70, 56 + offset)], fill=PINK) + # ojos felices (arcos) + d.arc([(18, 36 + offset), (32, 50 + offset)], 200, 340, fill=BLACK, width=3) + d.arc([(48, 36 + offset), (62, 50 + offset)], 200, 340, fill=BLACK, width=3) + # boca sonrisa abierta + d.arc([(28, 54 + offset), (52, 72 + offset)], 10, 170, fill=BLACK, width=3) + frames.append(img) + save_anim(frames, "02_happy.gif", duration=80) + + +# ─────────────────── 3. WORRIED ─────────────────── +def gen_worried(): + print("Generando 03_worried.gif") + frames = [] + for i in range(10): + img = new_canvas() + d = ImageDraw.Draw(img) + # cejas en pico + draw_face(d, eye_y=42, mouth_kind="happy", + brow_l=(18, 16, 32, 22), brow_r=(48, 22, 62, 16), + open_pct=85, blush=False) + # gota de sudor + if i % 3 == 0: + d.polygon([(62, 12), (66, 18), (62, 24)], fill=LBLUE, outline=BLACK) + frames.append(img) + save_anim(frames, "03_worried.gif", duration=120) + + +# ─────────────────── 4. SAD ─────────────────── +def gen_sad(): + print("Generando 04_sad.gif") + frames = [] + for i in range(8): + img = new_canvas() + d = ImageDraw.Draw(img) + offset = 0 if i < 4 else 2 + draw_face(d, eye_y=42, mouth_kind="sad", + brow_l=(18, 20, 32, 14), brow_r=(48, 14, 62, 20), + open_pct=70, blush=False) + # lágrima cayendo + ty = 30 + offset + d.ellipse([(14, ty), (22, ty + 8)], fill=LBLUE, outline=BLACK, width=1) + frames.append(img) + save_anim(frames, "04_sad.gif", duration=150) + + +# ─────────────────── 5. CRY ─────────────────── +def gen_cry(): + print("Generando 05_cry.gif") + frames = [] + for i in range(10): + img = new_canvas() + d = ImageDraw.Draw(img) + # ojos cerrados llorando + d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) + # cejas tristes + d.line([(18, 18), (32, 24)], fill=BLACK, width=3) + d.line([(48, 24), (62, 18)], fill=BLACK, width=3) + # ojos cerrados (arcos hacia abajo) + d.arc([(18, 38), (32, 50)], 20, 160, fill=BLACK, width=3) + d.arc([(48, 38), (62, 50)], 20, 160, fill=BLACK, width=3) + # boca muy triste + d.arc([(28, 60), (52, 76)], 190, 350, fill=BLACK, width=3) + # lágrimas cayendo + t1y = 30 + (i * 3) % 30 + t2y = 35 + (i * 4) % 25 + d.ellipse([(20, t1y), (24, t1y + 5)], fill=LBLUE, outline=BLACK, width=1) + d.ellipse([(56, t2y), (60, t2y + 5)], fill=LBLUE, outline=BLACK, width=1) + frames.append(img) + save_anim(frames, "05_cry.gif", duration=100) + + +# ─────────────────── 6. SURPRISED ─────────────────── +def gen_surprised(): + print("Generando 06_surprised.gif") + frames = [] + for i in range(8): + img = new_canvas() + d = ImageDraw.Draw(img) + draw_face(d, eye_y=40, mouth_kind="diamond", + brow_l=(28, 8, 32, 8), brow_r=(48, 8, 52, 8), + open_pct=100, blush=False) + # vibración + if i % 2 == 0: + d.line([(0, 0), (10, 0)], fill=BLACK) + d.line([(0, 4), (10, 4)], fill=BLACK) + d.line([(70, 0), (80, 0)], fill=BLACK) + d.line([(70, 4), (80, 4)], fill=BLACK) + frames.append(img) + save_anim(frames, "06_surprised.gif", duration=100) + + +# ─────────────────── 7. ANGRY ─────────────────── +def gen_angry(): + print("Generando 07_angry.gif") + frames = [] + for i in range(10): + img = new_canvas() + d = ImageDraw.Draw(img) + # temblor + ox = (1 if i % 2 == 0 else -1) + oy = (1 if i % 3 == 0 else 0) + d.ellipse([(6 + ox, 6 + oy), (SIZE - 6 + ox, SIZE - 6 + oy)], + fill=SKIN, outline=BLACK, width=2) + # blush rojo + d.ellipse([(10, 48), (24, 56)], fill=RED) + d.ellipse([(56, 48), (70, 56)], fill=RED) + # cejas en pico invertido + d.line([(18, 14), (32, 24)], fill=BLACK, width=3) + d.line([(48, 24), (62, 14)], fill=BLACK, width=3) + # ojos entrecerrados + d.ellipse([(18, 38), (30, 44)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(50, 38), (62, 44)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(22, 40), (26, 42)], fill=BLACK) + d.ellipse([(54, 40), (58, 42)], fill=BLACK) + # boca fruncida + d.line([(32, 64), (48, 64)], fill=BLACK, width=3) + # vena de enfado + if i % 2 == 0: + d.line([(64, 14), (74, 24)], fill=RED, width=2) + d.line([(64, 14), (74, 14)], fill=RED, width=2) + frames.append(img) + save_anim(frames, "07_angry.gif", duration=80) + + +# ─────────────────── 8. SLEEPY ─────────────────── +def gen_sleepy(): + print("Generando 08_sleepy.gif") + frames = [] + for i in range(12): + img = new_canvas() + d = ImageDraw.Draw(img) + # cabeceo + offset = 0 if i < 6 else 4 + d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], + fill=SKIN, outline=BLACK, width=2) + # cejas relajadas + d.line([(22, 22), (28, 20)], fill=BLACK, width=2) + d.line([(52, 20), (58, 22)], fill=BLACK, width=2) + # ojos cerrados (líneas horizontales) + d.line([(18, 42), (30, 42)], fill=BLACK, width=3) + d.line([(50, 42), (62, 42)], fill=BLACK, width=3) + # ZZZ + if i >= 4: + d.text((66, 8 - offset // 2), "z", fill=BLACK) + d.text((72, 4 - offset // 2), "z", fill=BLACK) + # boca neutral + d.line([(36, 60), (44, 60)], fill=BLACK, width=2) + frames.append(img) + save_anim(frames, "08_sleepy.gif", duration=150) + + +# ─────────────────── 9. WINK ─────────────────── +def gen_wink(): + print("Generando 09_wink.gif") + frames = [] + for i in range(10): + img = new_canvas() + d = ImageDraw.Draw(img) + d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) + d.ellipse([(10, 48), (24, 56)], fill=PINK) + d.ellipse([(56, 48), (70, 56)], fill=PINK) + # ceja izquierda arqueada + d.line([(18, 18), (32, 14)], fill=BLACK, width=3) + d.line([(48, 16), (62, 22)], fill=BLACK, width=3) + # ojo izquierdo abierto, derecho cerrado (parpadeo) + if i < 5: + d.ellipse([(18, 36), (30, 50)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(22, 41), (26, 45)], fill=BLACK) + d.line([(48, 43), (62, 43)], fill=BLACK, width=3) # cerrado + else: + d.line([(18, 43), (30, 43)], fill=BLACK, width=3) # cerrado + d.ellipse([(50, 36), (62, 50)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(54, 41), (58, 45)], fill=BLACK) + # sonrisa + d.arc([(28, 54), (52, 72)], 10, 170, fill=BLACK, width=3) + # destello + if i == 2: + d.line([(60, 28), (66, 34)], fill=YELLOW, width=2) + frames.append(img) + save_anim(frames, "09_wink.gif", duration=120) + + +# ─────────────────── 10. LOVE ─────────────────── +def gen_love(): + print("Generando 10_love.gif") + frames = [] + for i in range(10): + img = new_canvas() + d = ImageDraw.Draw(img) + d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) + d.ellipse([(10, 48), (24, 56)], fill=PINK) + d.ellipse([(56, 48), (70, 56)], fill=PINK) + # ojos en forma de corazón + for cx in (24, 56): + # dos círculos + triángulo para hacer corazón pixel art + d.ellipse([(cx - 6, 36), (cx, 42)], fill=RED) + d.ellipse([(cx, 36), (cx + 6, 42)], fill=RED) + d.polygon([(cx - 6, 41), (cx + 6, 41), (cx, 50)], fill=RED) + # sonrisa + d.arc([(28, 54), (52, 72)], 10, 170, fill=BLACK, width=3) + # corazón flotante + if i % 2 == 0: + hx = 56 + (i // 2) + hy = 24 - (i // 2) + d.ellipse([(hx - 4, hy - 2), (hx, hy + 2)], fill=RED) + d.ellipse([(hx, hy - 2), (hx + 4, hy + 2)], fill=RED) + d.polygon([(hx - 4, hy + 1), (hx + 4, hy + 1), (hx, hy + 8)], fill=RED) + frames.append(img) + save_anim(frames, "10_love.gif", duration=100) + + +# ─────────────────── 11. WORKING_HARD ─────────────────── +def gen_working(): + print("Generando 11_working.gif") + frames = [] + for i in range(8): + img = new_canvas() + d = ImageDraw.Draw(img) + # cejas concentradas + d.ellipse([(6, 6), (SIZE - 6, SIZE - 6)], fill=SKIN, outline=BLACK, width=2) + d.ellipse([(10, 48), (24, 56)], fill=PINK) + d.ellipse([(56, 48), (70, 56)], fill=PINK) + d.line([(18, 14), (32, 22)], fill=BLACK, width=3) + d.line([(48, 22), (62, 14)], fill=BLACK, width=3) + # ojos con mirada fija al frente + d.ellipse([(18, 38), (30, 50)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(50, 38), (62, 50)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(22, 42), (26, 46)], fill=BLACK) + d.ellipse([(54, 42), (58, 46)], fill=BLACK) + # boca apretada + d.rectangle([(32, 60), (48, 64)], fill=WHITE, outline=BLACK, width=1) + for x in range(34, 48, 3): + d.line([(x, 60), (x, 64)], fill=BLACK) + # gotas de sudor cayendo + sy = 30 + (i * 5) % 30 + d.polygon([(64, sy - 4), (68, sy), (64, sy + 4)], fill=LBLUE, outline=BLACK, width=1) + frames.append(img) + save_anim(frames, "11_working.gif", duration=100) + + +# ─────────────────── 12. EXCITED ─────────────────── +def gen_excited(): + print("Generando 12_excited.gif") + frames = [] + for i in range(10): + # bounce fuerte + offset = -3 if i % 2 == 0 else 3 + img = new_canvas() + d = ImageDraw.Draw(img) + d.ellipse([(6, 6 + offset), (SIZE - 6, SIZE - 6 + offset)], + fill=SKIN, outline=BLACK, width=2) + d.ellipse([(10, 48 + offset), (24, 56 + offset)], fill=PINK) + d.ellipse([(56, 48 + offset), (70, 56 + offset)], fill=PINK) + # ojos grandes abiertos + d.ellipse([(16, 36 + offset), (32, 52 + offset)], fill=WHITE, outline=BLACK, width=2) + d.ellipse([(48, 36 + offset), (64, 52 + offset)], fill=WHITE, outline=BLACK, width=2) + # pupilas bailando + pdx = -2 if i % 2 == 0 else 2 + d.ellipse([(22 + pdx, 42 + offset), (26 + pdx, 46 + offset)], fill=BLACK) + d.ellipse([(54 + pdx, 42 + offset), (58 + pdx, 46 + offset)], fill=BLACK) + # boca enorme + d.ellipse([(28, 54 + offset), (52, 70 + offset)], fill=RED, outline=BLACK, width=2) + # lengua + d.ellipse([(34, 62 + offset), (46, 70 + offset)], fill=PINK) + # destellos + if i % 2 == 0: + d.line([(8, 8), (16, 16)], fill=YELLOW, width=2) + d.line([(64, 8), (72, 16)], fill=YELLOW, width=2) + frames.append(img) + save_anim(frames, "12_excited.gif", duration=70) + + +# ─────────────────── MAIN ─────────────────── +if __name__ == "__main__": + print(f"Generando 12 GIFs en {OUT_DIR}/") + gen_neutral() + gen_happy() + gen_worried() + gen_sad() + gen_cry() + gen_surprised() + gen_angry() + gen_sleepy() + gen_wink() + gen_love() + gen_working() + gen_excited() + print(f"\nListo. {os.listdir(OUT_DIR)}") + # Tamaño total + total = sum(os.path.getsize(os.path.join(OUT_DIR, f)) for f in os.listdir(OUT_DIR)) + print(f"Tamaño total: {total} bytes ({total / 1024:.1f} KB)") diff --git a/include/User_Setup.h b/include/User_Setup.h new file mode 100644 index 0000000..2b8739f --- /dev/null +++ b/include/User_Setup.h @@ -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 diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..219e22f --- /dev/null +++ b/partitions.csv @@ -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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..8edf25c --- /dev/null +++ b/platformio.ini @@ -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 = + - + +; ───────────────────────────────────────────────────────────── +; 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 = + + +; ───────────────────────────────────────────────────────────── +; 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 = + diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8452c72 --- /dev/null +++ b/src/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +// -------------------- Pines -------------------- +#define PIN_BTN_NEXT 26 +#define PIN_BTN_DONE 27 +#define PIN_BTN_SNOOZE 14 +#define PIN_VIBRATOR 25 +#define PIN_TFT_BL 32 + +// -------------------- 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(); + 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); +} diff --git a/src/tests/diag.cpp b/src/tests/diag.cpp new file mode 100644 index 0000000..b1df232 --- /dev/null +++ b/src/tests/diag.cpp @@ -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 +#include +#include + +// Pines (los mismos del proyecto) +#define TFT_BL_PIN 32 +#define TFT_DC_PIN 4 +#define TFT_RST_PIN 2 + +TFT_eSPI tft = TFT_eSPI(); + +static void step(int n, const char *label) { + Serial.printf("\n[STEP %d] %s\n", n, label); + Serial.flush(); +} + +static void testBacklight() { + step(1, "Backlight PWM 0 -> 255"); + ledcSetup(0, 5000, 8); + ledcAttachPin(TFT_BL_PIN, 0); + for (int d = 0; d <= 255; d += 15) { + ledcWrite(0, d); + delay(60); + } + ledcWrite(0, 255); // 100% y se queda ahí + delay(500); + Serial.println(" backlight al 100% (si no ves nada, el problema es BL o cable BL)"); + Serial.flush(); +} + +static void testColors() { + step(2, "Pintando colores sólidos"); + const uint16_t colors[] = { TFT_RED, TFT_GREEN, TFT_BLUE, TFT_WHITE, TFT_BLACK, TFT_YELLOW, TFT_CYAN, TFT_MAGENTA }; + const char *names[] = { "RED", "GREEN", "BLUE", "WHITE", "BLACK", "YELLOW", "CYAN", "MAGENTA" }; + for (int i = 0; i < 8; i++) { + tft.fillScreen(colors[i]); + tft.setTextColor((colors[i] == TFT_BLACK) ? TFT_WHITE : TFT_BLACK, colors[i]); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(4); + tft.drawString(names[i], 120, 120, 4); + Serial.printf(" %s\n", names[i]); + Serial.flush(); + delay(800); + } +} + +static void testSPI() { + step(3, "Leyendo RDDID del ST7789 (debe ser 0x52)"); + digitalWrite(TFT_DC_PIN, LOW); + SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); + SPI.transfer(0x04); // RDDID + uint8_t b1 = SPI.transfer(0x00); + uint8_t b2 = SPI.transfer(0x00); + uint8_t b3 = SPI.transfer(0x00); + SPI.endTransaction(); + Serial.printf(" Leido: 0x%02X 0x%02X 0x%02X (esperado: 0x00 0x00 0x52)\n", b1, b2, b3); + Serial.printf(" chip_id = 0x%02X -> %s\n", b3, (b3 == 0x52) ? "ST7789 OK" : "ST7789 NO detectado"); + Serial.flush(); +} + +static void testFinal() { + step(4, "Pantalla final"); + tft.fillScreen(TFT_NAVY); + tft.setTextColor(TFT_WHITE, TFT_NAVY); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(3); + tft.drawString("DummyBot", 120, 80, 4); + tft.setTextSize(2); + tft.drawString("pantalla OK", 120, 140, 2); + tft.setTextSize(1); + tft.drawString("(si ves esto, todo va bien)", 120, 175, 2); + Serial.println(" diagnóstico completo"); + Serial.flush(); +} + +void setup() { + Serial.begin(115200); + delay(1000); + Serial.println("\n=== DummyBot DIAG v1 ==="); + Serial.printf("Pines: BL=%d, DC=%d, RST=%d\n", TFT_BL_PIN, TFT_DC_PIN, TFT_RST_PIN); + Serial.flush(); + + pinMode(TFT_BL_PIN, OUTPUT); + pinMode(TFT_DC_PIN, OUTPUT); + pinMode(TFT_RST_PIN, OUTPUT); + + tft.init(); + tft.setRotation(0); + tft.fillScreen(TFT_BLACK); + + testBacklight(); + delay(1500); + testColors(); + delay(1500); + testSPI(); + delay(1500); + testFinal(); +} + +void loop() { + delay(1000); +} diff --git a/src/tests/spi_loopback.cpp b/src/tests/spi_loopback.cpp new file mode 100644 index 0000000..a55834f --- /dev/null +++ b/src/tests/spi_loopback.cpp @@ -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 +#include + +#define MOSI_PIN 23 +#define MISO_PIN 19 +#define SCK_PIN 18 +#define CS_PIN 5 // cualquiera que no se use, pongo a HIGH + +void setup() { + Serial.begin(115200); + delay(1000); + Serial.println("\n=== DummyBot SPI LOOPBACK TEST ==="); + Serial.printf("MOSI=%d MISO=%d SCK=%d CS=%d\n", MOSI_PIN, MISO_PIN, SCK_PIN, CS_PIN); + Serial.println("Conecta con un cable GPIO 23 <-> GPIO 19 y resetea la placa."); + Serial.flush(); + + pinMode(CS_PIN, OUTPUT); + digitalWrite(CS_PIN, HIGH); + + SPI.begin(SCK_PIN, MISO_PIN, MOSI_PIN, CS_PIN); + + // Test 1: enviar 0xA5 y leerlo + Serial.println("\n[TEST 1] Enviando 0xA5..."); + SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); + uint8_t rx = SPI.transfer(0xA5); + SPI.endTransaction(); + Serial.printf(" Enviado 0xA5, recibido 0x%02X -> %s\n", + rx, (rx == 0xA5) ? "OK" : "FALLO (¿cable MOSI-MISO?)"); + + // Test 2: ristra de 16 bytes + Serial.println("\n[TEST 2] Enviando 16 bytes..."); + uint8_t tx[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + uint8_t rxbuf[16] = {0}; + SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); + for (int i = 0; i < 16; i++) rxbuf[i] = SPI.transfer(tx[i]); + SPI.endTransaction(); + bool match = true; + for (int i = 0; i < 16; i++) { + if (rxbuf[i] != tx[i]) { + match = false; + Serial.printf(" [%2d] TX=0x%02X RX=0x%02X <- MISMATCH\n", i, tx[i], rxbuf[i]); + } + } + Serial.printf(" %s\n", match ? "TODOS IGUALES -> SPI del ESP32 OK" : "HAY DIFERENCIAS -> SPI del ESP32 ROTO (rarísimo)"); + + // Test 3: misma operación con la frecuencia del display (40 MHz) + Serial.println("\n[TEST 3] Mismo test @ 40 MHz..."); + bool match2 = true; + SPI.beginTransaction(SPISettings(40000000, MSBFIRST, SPI_MODE0)); + for (int i = 0; i < 16; i++) { + uint8_t v = SPI.transfer(tx[i]); + if (v != tx[i]) { match2 = false; break; } + } + SPI.endTransaction(); + Serial.printf(" %s\n", match2 ? "OK a 40 MHz" : "FALLO a 40 MHz (prueba 10 MHz o 20 MHz)"); + + Serial.println("\n=== FIN ==="); + Serial.flush(); +} + +void loop() { + delay(1000); +}