Código de búsqueda

Makerfabs MaTouch ESP32-S3 2.8" cámara ESP32-S3 Visión IA: Apunta la cámara y pregunta qué ve

Makerfabs MaTouch ESP32-S3 2.8" cámara ESP32-S3 Visión IA: Apunta la cámara y pregunta qué ve

La placa toma una foto, una IA describe lo que ve y dice la respuesta en voz alta

Apunta la cámara a algo, toca un botón y una IA te dice lo que está mirando: impreso en la pantalla y hablado a través del altavoz. Dos modos: nombra los objetos a la vista o describe a la persona frente a la cámara.

AI Vision - Point It At Something ejecutándose en la placa MaTouch AI ESP32-S3 AI Vision - Point It At Something ejecutándose en la placa MaTouch AI ESP32-S3

Cómo llega la imagen

La cámara produce un JPEG de 800×600 de aproximadamente 20-40 KB. La placa lo codifica en base64 en la PSRAM, lo que lo hace aproximadamente un tercio más grande como texto, y lo incrusta en la solicitud como un URI de datos. Los 8 MB de PSRAM son lo que hace que esto sea cómodo.

Descripción, nunca identificación

El modo PERSON describe lo que puede ver: estado de ánimo, gafas, lo que alguien parece estar haciendo. No te dirá quién es alguien, y eso es deliberado. Identificar personas por el rostro va contra las políticas de uso de OpenAI, y el servicio equivalente de identificación facial de Microsoft está bloqueado detrás de un proceso de aprobación al que los desarrolladores comunes no pueden simplemente registrarse. Cualquier tutorial que prometa identificación facial en la nube describe algo que no está disponible de forma general.

Si quieres una placa que reconozca a personas específicas, usa el proyecto 03b: lo hace sin conexión y nunca envía la cara de nadie a ningún lado.

La cámara tiene que detenerse por la red

El visor en vivo se apaga mientras se hace la pregunta. Esto no es cosmético: el controlador de la cámara en ejecución mantiene la memoria interna que necesita una conexión segura, y con la vista previa en ejecución la conexión simplemente falla. Apagar la cámara la libera. También significa que la respuesta se puede leer en lugar de ser repintada más de 25 veces por segundo.

La respuesta permanece hasta que la descartas

La respuesta permanece en pantalla hasta que tocas CLEAR: no hay temporizador. Mientras se muestra, un botón REPLAY reproduce la respuesta hablada nuevamente desde la memoria, sin una segunda llamada a la API y sin costo adicional.

Acerca de la placa MaTouch AI ESP32-S3 2.8"

Cada proyecto en esta página se ejecuta en la MaTouch AI ESP32-S3 2.8" TFT ST7789V de Makerfabs. Es una placa todo en uno: una pantalla táctil a color, una cámara de 3 megapíxeles, dos micrófonos y un amplificador de altavoz real, todo impulsado por un ESP32-S3 con 8 MB de PSRAM. Esa combinación es lo que hace posibles estos proyectos de IA en una sola placa sin nada más conectado.

Los 8 MB de PSRAM importan más que cualquier otro número aquí. Es lo que permite que la placa tenga en memoria al mismo tiempo un fotograma de cámara, unos segundos de audio grabado o una foto codificada en base64, nada de lo cual cabe en la RAM normal del ESP32.

Documentación del fabricante: página wiki de Makerfabs.

Especificaciones clave

  • Procesador: ESP32-S3, doble núcleo a 240 MHz, WiFi 2.4 GHz + Bluetooth 5.0

  • Memoria: 16 MB de flash, 8 MB de PSRAM (requeridos por casi todos los proyectos aquí)

  • Pantalla: IPS de 2.8", 320×240, controlador ST7789V, SPI

  • Táctil: GT911 capacitivo, rastrea 5 dedos a la vez

  • Cámara: OV3660, 3 megapíxeles, hasta 2048×1536

  • Micrófonos: dos micrófonos digitales I2S INMP441 (un par estéreo genuino)

  • Altavoz: amplificador clase D MAX98357A, 3.2 W en 4 Ω

  • Almacenamiento: ranura para tarjeta microSD (modo SPI)

  • Alimentación: USB-C, conector de batería JST, cargador TP4056, interruptor de encendido

  • También en la placa: LED RGB WS2812B, reloj de tiempo real con batería PCF8563T y un medidor de batería MAX17048 que no está listado en las especificaciones oficiales

Los dos puertos USB-C no son iguales. El altavoz de la placa comparte sus pines de señal (IO19 e IO20) con el puerto USB nativo, porque esos pines son las líneas de datos USB cableadas del ESP32-S3. Siempre carga y alimenta a través del puerto USB-C CH340K (el que está junto al botón RESET) y configura USB CDC On Boot en Disabled. Usa el puerto equivocado y el audio se comportará mal o las cargas fallarán.

Configuración del IDE de Arduino

Estas configuraciones importan. La mayoría de los problemas que la gente reporta con esta placa son uno de estos siendo incorrecto, y se restablecen cuando cambias la versión del núcleo, así que revísalos nuevamente después de cualquier cambio.

Configuración

Valor

Placa

ESP32S3 Dev Module

Versión del núcleo ESP32

2.0.17

PSRAM

OPI PSRAM

Tamaño de flash

16MB (128Mb)

Esquema de particiones

16M Flash (3MB APP/9.9MB FATFS)

USB CDC On Boot

Disabled

Velocidad de carga

921600

Borrar todo el flash antes de cargar

Disabled

Puerto

el puerto USB-C CH340K

Usa el núcleo ESP32 2.0.17, no 3.x. Espressif eliminó los modelos de detección facial en el dispositivo en el núcleo 3, por lo que los proyectos de rostros no compilarán allí. Fijar 2.0.17 mantiene todos los proyectos en esta página funcionando con una sola configuración. En Boards Manager, el menú desplegable de versiones te permite cambiar de un lado a otro cuando quieras.

Usa la librería GFX Library for Arduino versión 1.5.6, no la 1.6.x. Las versiones 1.6 están diseñadas para el núcleo ESP32 3 y pueden colgarse al inicio en el núcleo 2.0.17. Si tu pantalla se queda en negro después de cargar, esto es lo primero que debes revisar.

Librerías requeridas

Instálalas a través de Herramientas → Gestionar librerías en el IDE de Arduino. Los números de versión importan: por favor, usa los que se indican.

Librería

Versión

Autor

GFX Library for Arduino

1.5.6

moononournation

bb_captouch

1.3.1

Larry Bank

ArduinoJson

7.x

Benoit Blanchon

Adafruit NeoPixel

cualquiera reciente

Adafruit

Configurar secrets.h

Tus datos de WiFi y cualquier clave de API van en secrets.h, que se incluye en la descarga con valores de ejemplo. Abre esa pestaña en el IDE de Arduino y reemplázalos con los tuyos.

El WiFi debe ser de 2.4 GHz. El ESP32-S3 no puede ver una red de 5 GHz en absoluto. Si tu router combina ambas bandas bajo un mismo nombre (Asus lo llama Smart Connect), desactiva esa opción o dale a la banda de 2.4 GHz su propio nombre y úsalo en secrets.h.

Obtener tus claves de API

Este proyecto se comunica con un servicio de IA en la nube, así que necesitas tu propia clave. Si nunca has hecho esto antes, no te preocupes: es lo mismo que una contraseña que identifica tu cuenta ante el servicio. Lleva unos minutos, una sola vez.

Una clave no es una suscripción a un sitio web. Pagar por ChatGPT Plus, por ejemplo, no te da una clave de API: son dos productos separados con facturación separada. Necesitas una cuenta en la plataforma de desarrolladores, como se describe abajo.

OpenAI: los ojos

OpenAI proporciona el modelo de visión que mira las imágenes. Solo los proyectos que envían una imagen necesitan esta clave.

  1. Ve a platform.openai.com/api-keys e inicia sesión o crea una cuenta.

  2. Haz clic en Create new secret key, ponle un nombre y créala.

  3. Cópiala inmediatamente: como DeepSeek, solo se muestra una vez.

  4. Abre Billing y añade una pequeña cantidad de crédito. La API es prepago y está separada de cualquier suscripción a ChatGPT que ya tengas.

Pon la clave en secrets.h como OPENAI_KEY.

Microsoft Azure Speech: para escuchar y hablar

Azure convierte tu voz en texto y convierte la respuesta de nuevo en voz. El nivel gratuito es más que suficiente para todo lo que hay en esta página.

  1. Ve a portal.azure.com e inicia sesión con una cuenta de Microsoft (una gratuita es suficiente).

  2. Si nunca has usado Azure antes, verás una pantalla de Welcome to Azure con tres opciones. Elige Start with an Azure free trial: necesitas una suscripción antes de que Azure te permita crear nada. (Los estudiantes deberían elegir Azure for Students en su lugar: mismo resultado, sin necesidad de tarjeta.) Ignora Manage Microsoft Entra ID, que es algo completamente distinto.

  3. Haz clic en Create a resource, busca Speech y elige Speech service publicado por Microsoft.

  4. Completa el formulario: cualquier grupo de recursos, cualquier nombre, y elige una Región cercana a ti: anota esa región exactamente como aparece, por ejemplo eastus.

  5. Para Pricing tier elige F0 (Free). Eso permite unas cinco horas de voz a texto y medio millón de caracteres de texto a voz cada mes.

  6. Haz clic en Review + create y luego en Create. Espera alrededor de un minuto y luego haz clic en Go to resource.

  7. En el menú de la izquierda abre Keys and Endpoint. Copia KEY 1 y la Location/Region.

Pon esos valores en secrets.h como AZURE_SPEECH_KEY y AZURE_REGION. Para AZURE_STT_HOST, usa <región>.stt.speech.microsoft.com: así, con la región eastus, sería eastus.stt.speech.microsoft.com.

Sobre la tarjeta de crédito. La prueba gratuita de Azure pide una tarjeta para verificar tu identidad. No te cobra. Obtienes $200 de crédito durante 30 días, y después la cuenta pasa a Pago por Uso, pero el nivel F0 de Speech sigue siendo gratuito, mes tras mes, y todo en estos proyectos cabe cómodamente dentro de él. Si prefieres no dar una tarjeta en absoluto y eres estudiante, la opción Azure for Students te da crédito sin necesidad de una.

Debe ser un recurso de "Speech service". Una clave de un recurso de Translator, Language o Cognitive Services general se ve idéntica y es perfectamente válida, pero cada solicitud de voz devuelve el error 401. Esto nos pasó durante las pruebas y nos costó una hora. Si el habla falla con 401 mientras la clave parece correcta, verifica qué tipo de recurso creaste.

Cuánto cuesta ejecutarlo

Muy poco, pero no es gratis, y deberías saber aproximadamente cuánto estás gastando antes de dejar un proyecto en funcionamiento.

Servicio

Costo aproximado

Azure Speech

el nivel gratuito cubre unas 5 horas de escucha y 0,5 M de caracteres de habla por mes

DeepSeek

una fracción de un centavo por respuesta: miles de respuestas por unos pocos dólares

OpenAI vision

aproximadamente uno o dos centavos por imagen, según el modelo

Los precios cambian, así que trátalos como una guía y no como un presupuesto. Cada uno de estos servicios tiene una página de uso donde puedes ver cuánto has gastado, y todos te permiten establecer un límite de gasto, algo que vale la pena hacer desde el primer día.

Mantén tus claves privadas. Cualquiera que las tenga puede gastar tu dinero. No las pongas en un video, una captura de pantalla, una publicación en un foro o un repositorio de código público. Si una clave se expone alguna vez, elimínala en el sitio web del proveedor y crea una nueva: toma segundos y es la única solución real.

Solución de problemas

Síntoma

Causa y solución

La pantalla permanece en negro

Versión incorrecta de la biblioteca GFX (usa 1.5.6) o configuración incorrecta de la placa.

PSRAM alloc failed o error de cámara 0xffffffff

Tools → PSRAM no está configurado en OPI PSRAM.

No se sube nada / no hay puerto COM

Puerto USB-C incorrecto, o el controlador CH340 no está instalado.

La cámara falla y nunca se recupera

La línea de reinicio de la cámara está vinculada al botón RESET de la placa, por lo que el software no puede reiniciarla. Pulsa RESET. Si aún falla, vuelve a colocar el cable plano de la cámara.

Descargar el código

El boceto completo de Arduino para este proyecto, junto con pins.h y todo lo demás que necesita, está disponible para descargar de forma gratuita.

Descargar 05_Vision_AI

Descomprímelo, abre el archivo .ino en el IDE de Arduino, verifica la configuración anterior y súbelo a través del puerto USB-C CH340K.

885-Arduin code for MaTouch AI ESP32S3 2.8in AI Camera: AI Vision
Idioma: C++
/*
 * ===========================================================================
 * 05_Visión_AI - MaTouch AI ESP32-S3 2.8" TFT ST7789V
 * ===========================================================================
 * 
 * ---------------------------------------------------------------------------
 * INSTRUCCIONES EN VÍDEO Y ESCRITAS
 * 
 * Mira el video: https://youtu.be/Px3FT47RV2M
 * Artículo completo: https://robojax.com/RTJ852
 * ("Visión AI" - cableado, configuraciones, capturas de pantalla
 * y respuestas a los problemas comunes)
 * 
 * Ver el video primero te ahorrará tiempo - muestra la configuración del IDE 
 * de Arduino y las versiones de la biblioteca configuradas paso a paso.
 * ---------------------------------------------------------------------------
 * 
 * La placa VE. Apunta la cámara a algo, toca un botón, y un
 * modelo de visión de OpenAI te dice qué está mirando - dibujado en la pantalla 
 * y (opcionalmente) pronunciado por el propio altavoz de la placa a través de Azure TTS.
 * 
 * Dos modos, dos botones:
 * 
 * OBJETOS "¿Qué ves?" - nombra las cosas frente al lente.
 * PERSONA Describe a la persona en cuadro: gafas, expresión, qué 
 * está haciendo. SÓLO DESCRIPCIÓN - no dirá ni debe 
 * intentar decir QUIÉN es alguien. Identificar personas por su cara está 
 * en contra de las políticas de uso de OpenAI, y es la decisión correcta: 
 * dilo en el video, vale 15 segundos honestos.
 * 
 * ¿POR QUÉ OPENAI PARA ESTA DEMO Y NO DEEPSEEK?: La API de DeepSeek es solo de texto. 
 * No puede aceptar una imagen en absoluto. Azure OpenAI podría hacerlo, pero 
 * OpenAI simple es un punto final sin configuración de implementación - es lo más sencillo de seguir.
 * 
 * CÓMO VIAJA LA IMAGEN: el OV3660 nos da un JPEG directamente (800x600, 
 * ~40 KB). Lo codificamos en base64 en PSRAM (~55 KB de texto) y lo incrustamos en 
 * la solicitud JSON como un URI de datos. Los 8 MB de PSRAM hacen que esto sea trivial.
 * 
 * ---------------------------------------------------------------------------
 * COMPLETA secrets.h ANTES DE FLASHEAR
 * (necesita WIFI_*, OPENAI_*; AZURE_* solo si SPEAK_REPLIES es 1).
 * 
 * CONFIGURACIONES DE LA PLACA (menú Herramientas - CADA línea cuenta, incorrecto = pantalla negra 
 * o errores de compilación. Estas se restablecen cuando cambias núcleos - ¡revisa de nuevo!)
 * 
 * Placa: Módulo de desarrollo ESP32S3
 * Núcleo ESP32: 2.0.17
 * PSRAM: OPI PSRAM <-- requerido, los buffers de imagen viven ahí
 * Tamaño de Flash: 16MB (128Mb)
 * Esquema de Partición: 16M Flash (3MB APP/9.9MB FATFS)
 * USB CDC al iniciar: Desactivado <-- el altavoz comparte pines con USB nativo
 * Velocidad de Carga: 921600
 * Puerto: el puerto CH340K USB-C (el que está cerca de RESET)
 * 
 * BIBLIOTECAS
 * Biblioteca GFX para Arduino v1.5.6 (NO 1.6.x - ese se empareja con núcleo 3)
 * bb_captouch v1.3.1
 * ArduinoJson v7.x
 * Adafruit NeoPixel cualquier reciente
 * 
 * ---------------------------------------------------------------------------
 * FUNCIONES EN ESTE ESQUEMA
 * led(r,g,b) establece el color del LED de estado RGB
 * getTouch(&x,&y) lee el panel táctil, mapeado a coordenadas de pantalla
 * camStart(...) inicia la cámara en un formato/tamaño dado
 * camStartPreview() inicia la cámara en vivo RGB565 240x240
 * captureJpeg(&len) toma un JPEG de 800x600 en PSRAM (la cámara se queda APAGADA 
 * después - el llamador reinicia la vista previa)
 * readHttpResponse() lee una respuesta HTTPS, desglosándola adecuadamente
 * askVision(...) envía foto + prompt a OpenAI, devuelve la respuesta
 * spkInit() configura la salida de altavoz I2S
 * readExact(...) lee exactamente N bytes de una conexión TLS
 * speak(text) Azure TTS -> descarga voz en PSRAM -> reproduce
 * playLastAnswer() reproduce la voz guardada desde PSRAM (botón REPRODUCIR)
 * drawButton(...) dibuja un botón en la columna lateral
 * drawWifi() barras de señal WiFi + lectura de dBm
 * drawButtons() columna lateral normal (los dos botones de pregunta)
 * drawClearSide() columna lateral en modo respuesta (LIMPIAR + REPRODUCIR)
 * showAnswer(text) superposición de respuesta ajustada para palabras en el visor
 * lookAndTell(...) un ciclo completo: capturar -> preguntar -> mostrar -> hablar
 * setup() / loop() secuencia de arranque / visor + manejo táctil
 * 
 * Robojax.com
 * ===========================================================================
 */
#define SPEAK_REPLIES  1 // 1 = leer la respuesta en voz alta con Azure TTS, 0 = solo pantalla

/*
 * Brillo del LED de estado, 0-255. El WS2812 se alimenta desde la línea de potencia y es incómodamente brillante a plena potencia: 25 es más que visible en la cámara.
 */
#define LED_BRIGHTNESS 15

/*
 * ORIENTACIÓN DE LA CÁMARA
 * 1 = la cámara apunta en la MISMA dirección que la pantalla (como se envía la placa) - la imagen está reflejada para que se vea natural cuando la apuntas hacia ti.
 * 0 = doblaste la cinta para que el lente apunte ALEJADO de la pantalla (estilo teléfono, pantalla hacia ti / cámara hacia el sujeto).
 * Si la imagen parece invertida de izquierda a derecha, invierte este número.
 */
#define CAMERA_FACES_USER  1

#include <Arduino_GFX_Library.h>
#include <bb_captouch.h>
#include <Adafruit_NeoPixel.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include "mbedtls/base64.h"
#include "esp_camera.h"
#include "pins.h"
#include "secrets.h"

#if SPEAK_REPLIES
#include "driver/i2s.h"
#define WAV_HEADER_LEN 44
#endif

Arduino_ESP32SPI *bus = new Arduino_ESP32SPI(
    TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, TFT_MISO, HSPI, true);
Arduino_GFX *gfx = new Arduino_ST7789(bus, TFT_RES, 1, true);

BBCapTouch bbct;
Adafruit_NeoPixel rgb(RGB_LED_NUM, RGB_LED_PIN, NEO_GRB + NEO_KHZ800);

/*
 * --- los dos indicaciones de visión ------------------------------------------------
 * Mantén las respuestas breves: deben caber en una pantalla de 320x240 y, si se pronuncian, no deben dejar al presentador esperando incómodamente frente a la cámara.
 */
const char *PROMPT_OBJECTS =
    "Look at this photo from a small camera. In ONE short sentence of at most "
    "20 words, plain text only, name the main object(s) you see.";

const char *PROMPT_PERSON =
    "Look at this photo. If there is a person, describe them in ONE short "
    "sentence of at most 20 words: mood, glasses or not, what they are doing. "
    "Never guess who they are. If no person is visible, say so. Plain text only.";

/*
 * --- diseño: visor a la izquierda, botones a la derecha ------------------------------- */
 * #define BTN_X 242
 * #define BTN_W 78
 * #define BTN_H 52
 * #define BTN_OBJ_Y 4
 * #define BTN_PERSON_Y 62
 * 
 * void led(uint8_t r, uint8_t g, uint8_t b) { rgb.setPixelColor(0, rgb.Color(r, g, b)); rgb.show(); }
 * 
 * /* ===========================================================================
 *  Tacto
 * ===========================================================================
 */
bool getTouch(uint16_t *x, uint16_t *y) {
  TOUCHINFO ti;
  if (!bbct.getSamples(&ti)) return false;
  if (ti.count < 1) return false;
  *x = ti.y[0];
  *y = (ti.x[0] > 240) ? 0 : (240 - ti.x[0]);
  return true;
}


/*
 * Cámara - RGB565 para la vista en vivo; la captura en JPEG se realiza reiniciando el controlador, la misma técnica que en el boceto 02.
 */
bool mirrored = CAMERA_FACES_USER; // ver la definición en la parte superior del archivo

/*
 * El visor está DESACTIVADO mientras se realiza una llamada en la nube y mientras la respuesta está en pantalla. Dos razones, ambas aprendidas en el banco de pruebas:  
 * 1. RAM: el controlador de la cámara en vivo consume la memoria interna que necesita un apretón de manos TLS; con la vista previa activa, la conexión a OpenAI falla (HTTP -1).  
 * 2. Legibilidad: el visor se repinta 25 veces por segundo y borraría la respuesta.  
 * La respuesta PERMANECE en pantalla hasta que el usuario toque CLARO - sin temporizador.
 */
bool preview_on = false;

/*
 * La última respuesta hablada se MANTIENE en PSRAM para que el botón de REPRODUCIR pueda volver a reproducirla sin otra llamada a la nube. Ser reemplazada por la siguiente respuesta.
 */
uint8_t *last_audio = nullptr;
size_t   last_audio_len = 0;

bool camStart(pixformat_t fmt, framesize_t size, int fb_count, int quality) {
  camera_config_t c;
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = CAM_PIN_D0;  c.pin_d1 = CAM_PIN_D1;
  c.pin_d2 = CAM_PIN_D2;  c.pin_d3 = CAM_PIN_D3;
  c.pin_d4 = CAM_PIN_D4;  c.pin_d5 = CAM_PIN_D5;
  c.pin_d6 = CAM_PIN_D6;  c.pin_d7 = CAM_PIN_D7;
  c.pin_xclk     = CAM_PIN_XCLK;
  c.pin_pclk     = CAM_PIN_PCLK;
  c.pin_vsync    = CAM_PIN_VSYNC;
  c.pin_href     = CAM_PIN_HREF;
/*
 * Comparte el bus I2C que ya maneja Wire (el panel táctil también está allí) en lugar de permitir que la cámara instale un segundo controlador en los mismos pines - eso mata la función táctil. Wire.begin() debe ejecutarse antes de esta función.
 */
  c.pin_sccb_sda = -1;
  c.pin_sccb_scl = -1;
  c.sccb_i2c_port = 0; // Cable = puerto I2C 0
  c.pin_pwdn     = CAM_PIN_PWDN;
  c.pin_reset    = CAM_PIN_RESET;
  c.xclk_freq_hz = 20000000;
  c.frame_size   = size;
  c.pixel_format = fmt;
  c.grab_mode    = CAMERA_GRAB_WHEN_EMPTY;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.jpeg_quality = quality;
  c.fb_count     = fb_count;

  if (esp_camera_init(&c) != ESP_OK) return false;

  sensor_t *s = esp_camera_sensor_get();
  if (s) {
    s->set_hmirror(s, mirrored ? 1 : 0);
    s->set_vflip(s,   mirrored ? 1 : 0);
    s->set_brightness(s, 1);
  }
  return true;
}

bool camStartPreview() { return camStart(PIXFORMAT_RGB565, FRAMESIZE_240X240, 2, 12); }

/*
 * Captura un JPEG SVGA en un búfer PSRAM que posee el llamador. */
 * uint8_t *capturarJpeg(size_t *len_out) {
 *  esp_camera_deinit();
 *  delay(120);
 *  if (!camStart(PIXFORMAT_JPEG, FRAMESIZE_SVGA, 1, 12)) { *len_out = 0; return nullptr; }
 * 
 *  // algunos fotogramas de calentamiento para que se estabilice la exposición
 *  for (int i = 0; i < 3; i++) {
 *  camera_fb_t *w = esp_camera_fb_get();
 *  if (w) esp_camera_fb_return(w);
 *  delay(100);
 *  }
 * 
 *  uint8_t *copia = nullptr;
 * len_out = 0;
 *  camera_fb_t *fb = esp_camera_fb_get();
 *  if (fb && fb->len > 0) {
 *  copia = (uint8_t *)ps_malloc(fb->len);
 *  if (copia) { memcpy(copia, fb->buf, fb->len); *len_out = fb->len; }
 *  }
 *  if (fb) esp_camera_fb_return(fb);
 * 
 *  /* Dejar deliberadamente la cámara APAGADA aquí. El llamador reinicia la vista previa
 * después de la llamada a la nube - un controlador de cámara en funcionamiento agota el apretón de manos TLS
 * de la RAM interna (ese fue el error "Vision HTTP -1").
 */
  esp_camera_deinit();
  delay(120);
  return copy;
}


/*
 * Lector de respuestas HTTP - compartido por las llamadas a la nube a continuación. Devuelve el código de estado y llena body_out. Maneja la codificación de transferencia en fragmentos CORRECTAMENTE: los marcadores de tamaño de fragmento deben ser eliminados, o acabarán embebidos dentro del JSON y el análisis fallará. (OpenAI da formato a sus respuestas de manera que abarcan varios fragmentos - esto nos afectó en la prueba.)
 */
/*
 * Bloquee hasta que la conexión tenga datos (o hasta que se acabe el presupuesto). Cada lectura a continuación pasa por esto, porque el servidor puede estar en silencio durante muchos segundos mientras analiza la imagen, y una lectura simple read() simplemente se agotaría.
 */
static bool waitData(WiFiClientSecure &c, uint32_t ms) {
  uint32_t t0 = millis();
  while (!c.available()) {
    if (!c.connected()) return false;
    if (millis() - t0 > ms) return false;
    delay(10);
  }
  return true;
}

static int readHttpResponse(WiFiClientSecure &client, String &body_out, uint32_t idle_ms) {
  body_out = "";

  if (!waitData(client, idle_ms)) { Serial.println("HTTP: no response at all"); return 0; }
  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);

  bool chunked = false;
  while (waitData(client, idle_ms)) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break; // línea en blanco = fin de los encabezados
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
  }

  if (chunked) {
    int blanks = 0;
    while (true) {
/*
 * Esperar aquí - en lugar de permitir que read() se agote - es esencial: una lectura que se agota se ve exactamente como "0" (último fragmento), lo que trunca silenciosamente el cuerpo a nada mientras el servidor todavía está pensando.
 */
      if (!waitData(client, idle_ms)) {
        Serial.println("HTTP: timed out waiting for the next chunk");
        break;
      }
      String szline = client.readStringUntil('\n');
      szline.trim();
      if (szline.length() == 0) {
        if (++blanks > 4) break;
        continue;
      }
      blanks = 0;

      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break; // trozo final genuino

      long got = 0;
      while (got < sz) {
        if (!waitData(client, idle_ms)) break;
        while (client.available() && got < sz) { body_out += (char)client.read(); got++; }
      }
      if (waitData(client, 3000)) client.readStringUntil('\n');
      if (got < sz) { Serial.println("HTTP: short chunk"); break; }
    }
  } else {
    while (waitData(client, idle_ms))
      while (client.available()) body_out += (char)client.read();
  }
  return code;
}


/*
 * Llamada de visión de OpenAI  
 * El cuerpo de la solicitud se construye a mano en PSRAM en lugar de a través de ArduinoJson, porque incrustar una cadena base64 de 55 KB en un documento JSON significaría tener dos copias. El texto en base64 nunca necesita escape JSON, por lo que esto es seguro.
 */
bool askVision(const uint8_t *jpg, size_t jpg_len, const char *prompt, String &answer_out) {
 // --- codificar la imagen en base64 en PSRAM ---
  size_t b64_cap = ((jpg_len + 2) / 3) * 4 + 16;
  unsigned char *b64 = (unsigned char *)ps_malloc(b64_cap);
  if (!b64) return false;
  size_t b64_len = 0;
  if (mbedtls_base64_encode(b64, b64_cap, &b64_len, jpg, jpg_len) != 0) {
    free(b64);
    return false;
  }

 // ensambla la solicitud JSON a su alrededor
/*
 * Los modelos actuales de OpenAI rechazan el antiguo nombre "max_tokens" - debe ser "max_completion_tokens" (verificado contra la API en vivo, julio de 2026).
 */
  const char *head_fmt =
      "{\"model\":\"%s\",\"max_completion_tokens\":%d,\"messages\":[{\"role\":\"user\","
      "\"content\":[{\"type\":\"text\",\"text\":\"%s\"},"
      "{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/jpeg;base64,";
  const char *tail = "\"}}]}]}";

  char head[640];
  int head_len = snprintf(head, sizeof(head), head_fmt, OPENAI_MODEL, LLM_MAX_TOKENS, prompt);

  size_t body_len = head_len + b64_len + strlen(tail);
  char *body = (char *)ps_malloc(body_len + 1);
  if (!body) { free(b64); return false; }
  memcpy(body, head, head_len);
  memcpy(body + head_len, b64, b64_len);
  strcpy(body + head_len + b64_len, tail);
  free(b64);

 // --- envíalo: carga HTTP manual, por fragmentos (patrón probado desde 04) ---
  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(25);

  if (!client.connect(OPENAI_HOST, 443)) {
    Serial.println("Vision: TLS connect failed (is the camera still running?)");
    free(body);
    return false;
  }

  client.print(String("POST /v1/chat/completions HTTP/1.1\r\n"
               "Host: " OPENAI_HOST "\r\n"
               "Authorization: Bearer " OPENAI_KEY "\r\n"
               "Content-Type: application/json\r\n"
               "Connection: close\r\n"
               "Content-Length: ") + String(body_len) + "\r\n\r\n");

  size_t sent = 0;
  while (sent < body_len) {
    size_t n = min((size_t)4096, body_len - sent);
    size_t w = client.write((uint8_t *)body + sent, n);
    if (w == 0) {
      delay(50);
      w = client.write((uint8_t *)body + sent, n);
      if (w == 0) break;
    }
    sent += w;
    yield();
  }
  free(body);
  if (sent < body_len) {
    Serial.printf("Vision: upload stalled at %u/%u bytes\n",
                  (unsigned)sent, (unsigned)body_len);
    client.stop();
    return false;
  }

/*
 * lee la respuesta con el desagregado adecuado */
 *  String resp;
 *  int code = readHttpResponse(client, resp, 30000);
 *  client.stop();
 * 
 *  if (code != 200) {
 *  Serial.printf("Vision HTTP %d: %s\n", code, resp.c_str());
 *  return false;
 *  }
 * 
 *  bool ok = false;
 *  JsonDocument doc;
 *  if (!deserializeJson(doc, resp)) {
 *  const char *content = doc["choices"][0]["message"]["content"];
 *  if (content) {
 *  answer_out = String(content);
 *  answer_out.trim();
 *  ok = answer_out.length() > 0;
 *  }
 *  } else {
 *  Serial.printf("Vision: fallo al analizar JSON, %u bytes recibidos\n", resp.length());
 *  }
 *  return ok;
 * }
 * 
 * /* ===========================================================================
 *  Azure TTS - el mismo truco de streaming que el sketch 04: PCM sin procesar en I2S.
 * ===========================================================================
 */
#if SPEAK_REPLIES
void spkInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = 16000,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S_SPK_BCLK,
    .ws_io_num = I2S_SPK_LRC,
    .data_out_num = I2S_SPK_DOUT,
    .data_in_num = I2S_PIN_NO_CHANGE
  };
  i2s_driver_install(I2S_SPK_PORT, &cfg, 0, NULL);
  i2s_set_pin(I2S_SPK_PORT, &pins);
  i2s_zero_dma_buffer(I2S_SPK_PORT);
}

/*
 * Leer exactamente n bytes de un cliente (o hasta que se agote el tiempo). */
 * static size_t readExact(WiFiClientSecure &c, uint8_t *dst, size_t n) {
 *  size_t got = 0;
 *  uint32_t t0 = millis();
 *  while (got < n && millis() - t0 < 10000) {
 *  int r = c.read(dst + got, n - got);
 *  if (r > 0) { got += r; t0 = millis(); }
 *  else if (!c.connected() && !c.available()) break;
 *  else delay(2);
 *  }
 *  return got;
 * }
 * 
 * void playLastAnswer(); // definido a continuación; prototipo explícito para el IDE
 * 
 * void speak(const String &text) {
 *  String safe = text;
 *  safe.replace("&", "&amp;");
 *  safe.replace("<", "&lt;");
 *  safe.replace(">", "&gt;");
 *  String ssml = "<speak version='1.0' xml:lang='" AZURE_TTS_LANG "'>"
 *  "<voice name='" AZURE_TTS_VOICE "'>" + safe + "</voice></speak>";
 * 
 *  /* HTTP manual con un desenrollado adecuado: Azure envía este audio en fragmentos, y
 * HTTPClient's flujo en crudo filtra las líneas del tamaño del fragmento ASCII en el PCM -
 * cada uno se reproduce como un KNOCK audible.
 */
  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(20);

  const char *host = AZURE_REGION ".tts.speech.microsoft.com";
  if (!client.connect(host, 443)) { Serial.println("TTS: TLS connect failed"); return; }

  client.print(String("POST /cognitiveservices/v1 HTTP/1.1\r\n"
               "Host: ") + host + "\r\n"
               "Ocp-Apim-Subscription-Key: " AZURE_SPEECH_KEY "\r\n"
               "Content-Type: application/ssml+xml\r\n"
               "X-Microsoft-OutputFormat: riff-16khz-16bit-mono-pcm\r\n"
               "User-Agent: MaTouchRobojax\r\n"
               "Connection: close\r\n"
               "Content-Length: " + String(ssml.length()) + "\r\n\r\n");
  client.print(ssml);

  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);
  bool chunked = false;
  long content_len = -1;
  while (client.connected() || client.available()) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break;
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
    if (h.startsWith("content-length:")) content_len = h.substring(15).toInt();
  }
  if (code != 200) { Serial.printf("TTS HTTP %d\n", code); client.stop(); return; }

  const size_t AUDIO_CAP = 1200 * 1024;
  uint8_t *audio = (uint8_t *)ps_malloc(AUDIO_CAP);
  if (!audio) { client.stop(); return; }
  size_t alen = 0;

  if (chunked) {
    while (true) {
      String szline = client.readStringUntil('\n');
      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break;
      if (alen + sz > AUDIO_CAP) break;
      size_t got = readExact(client, audio + alen, sz);
      alen += got;
      client.readStringUntil('\n');
      if (got < (size_t)sz) break;
    }
  } else if (content_len > 0) {
    alen = readExact(client, audio, min((size_t)content_len, AUDIO_CAP));
  } else {
    uint32_t idle = millis();
    while ((client.connected() || client.available()) && millis() - idle < 5000) {
      int r = client.read(audio + alen, min((size_t)2048, AUDIO_CAP - alen));
      if (r > 0) { alen += r; idle = millis(); }
      else delay(5);
    }
  }
  client.stop();

  if (alen > WAV_HEADER_LEN) {
/*
 * mantén esta respuesta para el botón de REPRODUCCIÓN (reemplazando la anterior), luego reprodúcela
 */
    if (last_audio) free(last_audio);
    last_audio = audio;
    last_audio_len = alen;
    playLastAnswer();
  } else {
    free(audio);
  }
}

/*
 * Reproduce la respuesta guardada de PSRAM - utilizada justo después de la descarga Y por REPLAY. */
 * void playLastAnswer() {
 *  if (!last_audio || last_audio_len <= WAV_HEADER_LEN) return;
 *  static const uint8_t lead_in[640] = {0}; // 20 ms de silencio previo
 *  size_t w = 0;
 *  i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
 *  i2s_write(I2S_SPK_PORT, last_audio + WAV_HEADER_LEN,
 *  last_audio_len - WAV_HEADER_LEN, &w, portMAX_DELAY);
 *  i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
 *  delay(150);
 *  i2s_zero_dma_buffer(I2S_SPK_PORT);
 * }
 * #endif // SPEAK_REPLIES
 * 
 * /* ===========================================================================
 *  UI
 * ===========================================================================
 */
void drawButton(int y, const char *l1, const char *l2, uint16_t colour) {
  gfx->fillRoundRect(BTN_X, y, BTN_W, BTN_H, 6, colour);
  gfx->drawRoundRect(BTN_X, y, BTN_W, BTN_H, 6, WHITE);
  gfx->setTextSize(1);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_X + 8, y + 14);
  gfx->print(l1);
  gfx->setCursor(BTN_X + 8, y + 28);
  gfx->print(l2);
}

/*
 * Barras de WiFi + dBm en la parte inferior de la columna lateral, actualizadas en el bucle */
 * void dibujarWiFi() {
 *  gfx->fillRect(BTN_X, 188, BTN_W, 52, NEGRO);
 *  bool up = (WiFi.status() == WL_CONNECTED);
 *  long rssi = up ? WiFi.RSSI() : -100;
 *  int barras = rssi > -55 ? 4 : rssi > -65 ? 3 : rssi > -75 ? 2 : rssi > -85 ? 1 : 0;
 * 
 *  for (int b = 0; b < 4; b++) {
 *  int bh = 6 + b * 6;
 *  uint16_t col = (b < barras) ? VERDE : gfx->color565(60, 60, 60);
 *  gfx->fillRect(BTN_X + 4 + b * 9, 216 - bh, 7, bh, col);
 *  }
 *  gfx->setTextSize(1);
 *  gfx->setCursor(BTN_X + 44, 196);
 *  if (up) {
 *  gfx->setTextColor(CIAN);
 *  gfx->printf("%ld", rssi);
 *  } else {
 *  gfx->setTextColor.ROJO);
 *  gfx->print("FUERA");
 *  }
 *  gfx->setCursor(BTN_X + 44, 208);
 *  gfx->setTextColor(gfx->color565(120, 120, 120));
 *  gfx->print("dBm");
 *  gfx->setCursor(BTN_X + 4, 228);
 *  gfx->setTextColor(gfx->color565(120, 120, 120));
 *  gfx->print("señal WiFi");
 * }
 * 
 * /* columna lateral normal: los dos botones de solicitud
 */
void drawButtons() {
  gfx->fillRect(BTN_X, 0, BTN_W, 188, BLACK);
  drawButton(BTN_OBJ_Y,    "WHAT DO",  "YOU SEE?", gfx->color565(0, 90, 160));
  drawButton(BTN_PERSON_Y, "DESCRIBE", "PERSON",   gfx->color565(120, 60, 140));

  gfx->setTextColor(CYAN);
  gfx->setCursor(BTN_X + 2, 128);
  gfx->print("OpenAI eyes");
  gfx->setCursor(BTN_X + 2, 140);
  gfx->print("Azure voice");
  gfx->setCursor(BTN_X + 2, 152);
  gfx->print("Robojax.com");

  drawWifi();
}

/*
 * modo de respuesta columna lateral: CLARO (de vuelta a la cámara) + REPRODUCIR (dilo de nuevo). La respuesta permanece en la pantalla hasta que se toque CLARO.
 */
void drawClearSide() {
  gfx->fillRect(BTN_X, 0, BTN_W, 188, BLACK);
  gfx->fillRoundRect(BTN_X, BTN_OBJ_Y, BTN_W, BTN_H, 6, gfx->color565(110, 35, 35));
  gfx->drawRoundRect(BTN_X, BTN_OBJ_Y, BTN_W, BTN_H, 6, WHITE);
  gfx->setTextSize(2);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_X + 10, BTN_OBJ_Y + 14);
  gfx->print("CLEAR");

#if SPEAK_REPLIES
  if (last_audio_len > 0) {
    gfx->fillRoundRect(BTN_X, BTN_PERSON_Y, BTN_W, BTN_H, 6, gfx->color565(0, 110, 60));
    gfx->drawRoundRect(BTN_X, BTN_PERSON_Y, BTN_W, BTN_H, 6, WHITE);
    gfx->setTextSize(1);
    gfx->setTextColor(WHITE);
    gfx->setCursor(BTN_X + 16, BTN_PERSON_Y + 20);
    gfx->print("REPLAY");
  }
#endif

  gfx->setTextSize(1);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(BTN_X + 2, 124);
  gfx->print("CLEAR = camera");
  gfx->setCursor(BTN_X + 2, 136);
  gfx->print("REPLAY = again");

  drawWifi();
}

/*
 * Overlay de respuesta ajustada en la parte inferior del visor. 
 * void showAnswer(const String &text) {
 *  const int max_chars = 38;
 *  int lines = (text.length() + max_chars - 1) / max_chars;
 *  if (lines > 6) lines = 6;
 *  int h = lines * 10 + 8;
 *  int y = 240 - h;
 * 
 *  gfx->fillRect(0, y, 240, h, gfx->color565(0, 0, 0));
 *  gfx->drawRect(0, y, 240, h, CYAN);
 *  gfx->setTextSize(1);
 *  gfx->setTextColor(WHITE);
 *  for (int i = 0; i < lines; i++) {
 *  gfx->setCursor(4, y + 5 + i * 10);
 *  gfx->print(text.substring(i * max_chars, min((int)text.length(), (i + 1) * max_chars)));
 *  }
 * }
 * 
 * /* ===========================================================================
 *  Un ciclo completo de "mira y cuenta"
 * ===========================================================================
 */
void lookAndTell(const char *prompt, const char *label) {
  led(255, 120, 0); // ámbar: trabajando
  preview_on = false; // la cámara se apaga aquí

  gfx->fillRect(0, 0, 240, 240, BLACK);
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(1);
  gfx->setCursor(30, 110);
  gfx->printf("capturing photo (%s)...", label);

  size_t jpg_len = 0;
  uint32_t t_cap = millis();
  uint8_t *jpg = captureJpeg(&jpg_len);
  t_cap = millis() - t_cap;

  if (!jpg || jpg_len == 0) {
    if (jpg) free(jpg);
    showAnswer("Capture failed - tap CLEAR to retry.");
    led(255, 0, 0);
    drawClearSide();
    return;
  }
  Serial.printf("Captured %u KB in %lu ms\n", (unsigned)(jpg_len / 1024), (unsigned long)t_cap);

  gfx->setCursor(30, 124);
  gfx->printf("asking OpenAI (%uKB)...", (unsigned)(jpg_len / 1024));

  String answer;
  uint32_t t_ai = millis();
  bool ok = askVision(jpg, jpg_len, prompt, answer);
  t_ai = millis() - t_ai;
  free(jpg);

  if (!ok) {
    showAnswer("No answer - see serial monitor for the reason.");
    led(255, 0, 0);
    drawClearSide();
    return;
  }

  Serial.printf("Vision (%lu ms): %s\n", (unsigned long)t_ai, answer.c_str());
  showAnswer(answer);
  drawClearSide(); // CLEAR reemplaza los botones de solicitud

#if SPEAK_REPLIES
  led(0, 255, 40); // verde: hablando
  speak(answer); // la respuesta se queda en la pantalla
#endif
  led(0, 0, 0);
/*
 * la respuesta ahora permanece hasta que el usuario toque LIMPIAR */ }
 * 
 * /* ===========================================================================  
 * CONFIGURACIÓN  
 * ===========================================================================
 */
void setup() {
  Serial.begin(115200);
  delay(400);
  Serial.println("\n=== 05 Vision AI  |  Robojax.com ===");

  pinMode(TFT_BLK, OUTPUT);
  digitalWrite(TFT_BLK, LOW);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(SD_CS, HIGH);

  gfx->begin();
  gfx->fillScreen(BLACK);
  digitalWrite(TFT_BLK, HIGH);

  bbct.init(TOUCH_SDA, TOUCH_SCL, TOUCH_RST, TOUCH_INT);
  delay(50);

 // El SCCB de la cámara comparte este bus, así que Wire debe estar activo antes que la cámara.
  Wire.begin(I2C_SDA, I2C_SCL, 100000);
  delay(20);

  rgb.begin();
  rgb.setBrightness(LED_BRIGHTNESS);
  led(0, 0, 0);

#if SPEAK_REPLIES
  spkInit();
#endif

  gfx->setTextSize(1);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(4, 4);
  gfx->printf("Connecting to %s ...", WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) delay(300);

  gfx->setCursor(4, 16);
  if (WiFi.status() == WL_CONNECTED) {
    gfx->setTextColor(GREEN);
    gfx->print("WiFi ok");
  } else {
    gfx->setTextColor(RED);
    gfx->print("WiFi FAILED (2.4GHz only! check secrets.h)");
  }

  gfx->setCursor(4, 28);
  gfx->setTextColor(YELLOW);
  gfx->print("starting camera...");
  if (!camStartPreview()) {
    gfx->fillScreen(RED);
    gfx->setTextColor(WHITE);
    gfx->setTextSize(2);
    gfx->setCursor(20, 100);
    gfx->print("CAMERA FAILED");
    gfx->setTextSize(1);
    gfx->setCursor(20, 130);
    gfx->print("Press RESET (camera reset = board reset)");
    while (1) delay(1000);
  }

  delay(400);
  gfx->fillScreen(BLACK);
  drawButtons();
  preview_on = true;
  Serial.println("Running. Touch a button to have the AI look through the camera.");
}


/*
 * CICLO
 */
void loop() {
  static uint32_t last_touch = 0;

  if (preview_on) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (fb) {
      gfx->draw16bitBeRGBBitmap(0, 0, (uint16_t *)fb->buf, fb->width, fb->height);
      esp_camera_fb_return(fb);
    }
  } else {
    delay(20); // respuesta en pantalla, esperando CLEAR
  }

/*
 * señal WiFi en vivo, refrescada cada 2 s en ambos modos */
 *  static uint32_t last_wifi = 0;
 *  if (millis() - last_wifi > 2000) {
 *  last_wifi = millis();
 *  drawWifi();
 *  }
 * 
 *  /* Tacto detectado por borde: una acción se activa solo en un nuevo toque, y el
 * dedo debe levantarse completamente (4 lecturas vacías consecutivas) antes de que
 * cualquier cosa pueda volver a activarse. Esto es lo que detiene un toque en BORRAR de también activar el
 * botón de captura que aparece en el mismo lugar un momento después.
 */
  static bool    touch_down = false;
  static uint8_t release_count = 0;

  uint16_t x, y;
  if (getTouch(&x, &y)) {
    release_count = 0;
    if (!touch_down) {
      touch_down = true; // nuevo toque - actuar exactamente una vez

      if (!preview_on) {

Archivos📁

Archivo Requerido (.h)

  • secrets.h
    archivo para el módulo Makerfabs MaTouch AI ESP32S3 2.8" TFT Camera
    secrets.h 0.01 MB

Otros Archivos

  • pins.h
    archivo de pines para MaTouch AI ESP32S3 2.8" cámara LCD pantalla táctil.
    pins.h 0.01 MB

Esquemático

  • MaTouch_AI 2.8" MaTouch AI ESP32S3 2.8" TFT ST7789V esquemático
    La última placa MaTouch AI integra entrada de voz I2S / altavoz I2S / cámara de 3 millones OV3660 / pantalla de resolución 320*240, con el potente procesador ESP32S3 y capacidad Wi-Fi, para hacer de esta placa una buena herramienta/plataforma para el desarrollo de IA con ESP32.
    MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF 0.15 MB