Suchcode

Makerfabs MaTouch ESP32-S3 2.8" Kamera ESP32-S3 KI-Vision: Richten Sie die Kamera und fragen Sie, was sie sieht

Makerfabs MaTouch ESP32-S3 2.8" Kamera ESP32-S3 KI-Vision: Richten Sie die Kamera und fragen Sie, was sie sieht

Das Board macht ein Foto, eine KI beschreibt, was sie sieht, und sagt die Antwort laut aus

Richten Sie die Kamera auf etwas, tippen Sie auf eine Schaltfläche, und eine KI sagt Ihnen, was sie sieht – auf dem Bildschirm angezeigt und über den Lautsprecher gesprochen. Zwei Modi: Benennen Sie die Objekte im Sichtfeld oder beschreiben Sie die Person vor der Kamera.

AI Vision - Point It At Something läuft auf dem MaTouch AI ESP32-S3 Board AI Vision – Point It At Something läuft auf dem MaTouch AI ESP32-S3 Board

Wie das Bild dorthin gelangt

Die Kamera erzeugt ein 800×600-JPEG von etwa 20–40 KB. Das Board kodiert es in PSRAM base64, wodurch es als Text etwa ein Drittel größer wird, und bettet es als Daten-URI in die Anfrage ein. Die 8 MB PSRAM machen das komfortabel möglich.

Beschreibung, niemals Identifizierung

Der PERSON-Modus beschreibt, was er sehen kann – Stimmung, Brille, was jemand zu tun scheint. Er wird Ihnen nicht sagen, wer jemand ist, und das ist beabsichtigt. Die Identifizierung von Personen anhand des Gesichts verstößt gegen die Nutzungsrichtlinien von OpenAI, und der entsprechende Gesichtserkennungsdienst von Microsoft ist hinter einem Genehmigungsprozess gesperrt, für den sich normale Entwickler nicht einfach anmelden können. Jedes Tutorial, das Ihnen Cloud-Gesichtserkennung verspricht, beschreibt etwas, das nicht allgemein verfügbar ist.

Wenn Sie ein Board möchten, das bestimmte Personen erkennt, verwenden Sie Projekt 03b – es arbeitet offline und sendet niemals das Gesicht einer Person irgendwohin.

Die Kamera muss für das Netzwerk stoppen

Die Live-Sucher wird ausgeschaltet, während die Frage gestellt wird. Das ist nicht kosmetisch: Der laufende Kamera-Treiber hält den internen Speicher, den eine sichere Verbindung benötigt, und mit laufender Vorschau schlägt die Verbindung einfach fehl. Das Ausschalten der Kamera gibt ihn frei. Es bedeutet auch, dass die Antwort lesbar bleibt, statt 25 Mal pro Sekunde neu gezeichnet zu werden.

Die Antwort bleibt, bis Sie sie verwerfen

Die Antwort bleibt auf dem Bildschirm, bis Sie CLEAR antippen – es gibt keinen Timer. Während sie angezeigt wird, spielt eine REPLAY-Schaltfläche die gesprochene Antwort erneut aus dem Speicher ab, ohne zweiten API-Aufruf und ohne zusätzliche Kosten.

Über das MaTouch AI ESP32-S3 2.8"-Board

Jedes Projekt auf dieser Seite läuft auf dem MaTouch AI ESP32-S3 2.8" TFT ST7789V von Makerfabs. Es ist ein All-in-One-Board: ein Farb-Touchscreen, eine 3-Megapixel-Kamera, zwei Mikrofone und ein echter Lautsprecherverstärker, alles gesteuert von einem ESP32-S3 mit 8 MB PSRAM. Diese Kombination macht diese KI-Projekte auf einem einzigen Board ohne zusätzliche Komponenten möglich.

Die 8 MB PSRAM sind wichtiger als jede andere Zahl hier. Sie ermöglichen es dem Board, gleichzeitig ein Kamerabild, einige Sekunden aufgenommenes Audio oder ein base64-kodiertes Foto im Speicher zu halten – nichts davon passt in den normalen RAM des ESP32.

Herstellerdokumentation: Makerfabs-Wiki-Seite.

Wichtige Spezifikationen

  • Prozessor: ESP32-S3, Dual-Core 240 MHz, WiFi 2,4 GHz + Bluetooth 5.0

  • Speicher: 16 MB Flash, 8 MB PSRAM (von fast jedem Projekt hier benötigt)

  • Display: 2,8" IPS, 320×240, ST7789V-Treiber, SPI

  • Touch: GT911 kapazitiv, erfasst 5 Finger gleichzeitig

  • Kamera: OV3660, 3 Megapixel, bis zu 2048×1536

  • Mikrofone: zwei INMP441 I2S-Digitalmikrofone (ein echtes Stereopaar)

  • Lautsprecher: MAX98357A-Klasse-D-Verstärker, 3,2 W an 4 Ω

  • Speicher: microSD-Kartensteckplatz (SPI-Modus)

  • Stromversorgung: USB-C, JST-Akkuanschluss, TP4056-Ladegerät, Netzschalter

  • Außerdem auf dem Board: WS2812B-RGB-LED, PCF8563T-Echtzeituhr mit Batteriepuffer und ein MAX17048-Akku-Fuel-Gauge, das nicht in den offiziellen Spezifikationen aufgeführt ist

Die beiden USB-C-Anschlüsse sind nicht gleich. Der Lautsprecher des Boards teilt sich seine Signalpins (IO19 und IO20) mit dem nativen USB-Anschluss, da diese Pins die fest verdrahteten USB-Datenleitungen des ESP32-S3 sind. Laden und übertragen Sie immer über den CH340K-USB-C-Anschluss (den neben der RESET-Taste) und setzen Sie USB CDC On Boot auf Disabled. Verwenden Sie den falschen Anschluss, und der Ton verhält sich fehlerhaft oder Uploads schlagen fehl.

Arduino-IDE-Einstellungen

Diese Einstellungen sind wichtig. Die meisten Probleme, die Menschen mit diesem Board melden, sind auf einen dieser Fehler zurückzuführen, und sie setzen sich zurück, wenn Sie die Core-Version ändern. Überprüfen Sie sie daher nach jeder Änderung erneut.

Einstellung

Wert

Board

ESP32S3 Dev Module

ESP32-Core-Version

2.0.17

PSRAM

OPI PSRAM

Flash-Größe

16MB (128Mb)

Partitionsschema

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

USB CDC On Boot

Disabled

Upload-Geschwindigkeit

921600

Erase All Flash Before Upload

Disabled

Port

der CH340K-USB-C-Anschluss

Verwenden Sie ESP32-Core 2.0.17, nicht 3.x. Espressif hat die On-Device-Gesichtserkennungsmodelle in Core 3 entfernt, daher lassen sich die Gesichtsprojekte dort nicht kompilieren. Das Festlegen auf 2.0.17 hält jedes Projekt auf dieser Seite mit einer Konfiguration funktionsfähig. Im Boards Manager können Sie über das Versions-Dropdown jederzeit hin- und herwechseln.

Verwenden Sie die GFX Library for Arduino Version 1.5.6, nicht 1.6.x. Die 1.6-Versionen sind für den ESP32-Kern 3 entwickelt und können beim Start auf Kern 2.0.17 hängen bleiben. Wenn Ihr Bildschirm nach dem Hochladen schwarz bleibt, ist dies das Erste, was Sie überprüfen sollten.

Erforderliche Bibliotheken

Installieren Sie diese über Werkzeuge → Bibliotheken verwalten in der Arduino-IDE. Versionsnummern sind wichtig – bitte verwenden Sie die aufgeführten.

Bibliothek

Version

Autor

GFX Library for Arduino

1.5.6

moononournation

bb_captouch

1.3.1

Larry Bank

ArduinoJson

7.x

Benoit Blanchon

Adafruit NeoPixel

beliebig aktuell

Adafruit

Einrichten von secrets.h

Ihre WLAN-Daten und alle API-Schlüssel gehören in secrets.h, das im Download mit Platzhalterwerten enthalten ist. Öffnen Sie diesen Tab in der Arduino-IDE und ersetzen Sie sie durch Ihre eigenen.

WLAN muss 2,4 GHz sein. Der ESP32-S3 kann ein 5 GHz-Netzwerk überhaupt nicht sehen. Wenn Ihr Router beide Bänder unter einem Namen kombiniert (Asus nennt das Smart Connect), deaktivieren Sie dies entweder oder geben Sie dem 2,4 GHz-Band einen eigenen Namen und verwenden Sie diesen in secrets.h.

Abrufen Ihrer API-Schlüssel

Dieses Projekt kommuniziert mit einem Cloud-KI-Dienst, daher benötigen Sie Ihren eigenen Schlüssel. Wenn Sie dies noch nie getan haben, keine Sorge – es ist dasselbe Prinzip wie ein Passwort, das Ihr Konto beim Dienst identifiziert. Es dauert einmalig ein paar Minuten.

Ein Schlüssel ist kein Abonnement für eine Website. Zum Beispiel gibt das Bezahlen für ChatGPT Plus keinen API-Schlüssel – die beiden sind separate Produkte mit separater Abrechnung. Sie benötigen ein Konto auf der Entwicklerplattform, wie unten beschrieben.

OpenAI – die Augen

OpenAI stellt das Vision-Modell bereit, das Bilder betrachtet. Nur Projekte, die ein Bild senden, benötigen dieses.

  1. Gehen Sie zu platform.openai.com/api-keys und melden Sie sich an oder erstellen Sie ein Konto.

  2. Klicken Sie auf Neuen geheimen Schlüssel erstellen, geben Sie ihm einen Namen und erstellen Sie ihn.

  3. Kopieren Sie ihn sofort – wie bei DeepSeek wird er nur einmal angezeigt.

  4. Öffnen Sie Abrechnung und fügen Sie eine kleine Menge Guthaben hinzu. Die API ist prepaid und getrennt von einem etwaigen ChatGPT-Abonnement, das Sie bereits haben.

Fügen Sie den Schlüssel in secrets.h als OPENAI_KEY ein.

Microsoft Azure Speech – zum Zuhören und Sprechen

Azure wandelt Ihre Sprache in Text um und die Antwort wieder in eine Stimme. Die kostenlose Stufe ist großzügig genug für alles auf dieser Seite.

  1. Gehen Sie zu portal.azure.com und melden Sie sich mit einem Microsoft-Konto an (ein kostenloses ist in Ordnung).

  2. Wenn Sie Azure noch nie verwendet haben, sehen Sie einen Bildschirm Willkommen bei Azure mit drei Optionen. Wählen Sie Mit einer kostenlosen Azure-Testversion starten – Sie benötigen ein Abonnement, bevor Azure Ihnen erlaubt, etwas zu erstellen. (Studenten sollten stattdessen Azure für Studenten wählen: gleiches Ergebnis, keine Karte erforderlich.) Ignorieren Sie Microsoft Entra ID verwalten, das etwas völlig anderes ist.

  3. Klicken Sie auf Ressource erstellen, suchen Sie nach Speech und wählen Sie Speech-Dienst, veröffentlicht von Microsoft.

  4. Füllen Sie das Formular aus: eine beliebige Ressourcengruppe, einen beliebigen Namen, und wählen Sie eine Region in Ihrer Nähe – notieren Sie diese Region genau, wie sie erscheint, z. B. eastus.

  5. Wählen Sie bei Tarif die Option F0 (Kostenlos). Das erlaubt etwa fünf Stunden Spracherkennung und eine halbe Million Zeichen Sprachsynthese pro Monat.

  6. Klicken Sie auf Überprüfen + erstellen, dann auf Erstellen. Warten Sie etwa eine Minute, dann klicken Sie auf Zu Ressource wechseln.

  7. Öffnen Sie im linken Menü Schlüssel und Endpunkt. Kopieren Sie SCHLÜSSEL 1 und die Region/Standort.

Fügen Sie diese in secrets.h als AZURE_SPEECH_KEY und AZURE_REGION ein. Für AZURE_STT_HOST verwenden Sie <region>.stt.speech.microsoft.com – also mit der Region eastus wäre das eastus.stt.speech.microsoft.com.

Zur Kreditkarte. Die kostenlose Azure-Testversion fragt nach einer Karte zur Identitätsprüfung. Sie belastet Sie nicht. Sie erhalten $200 Guthaben für 30 Tage, und danach wechselt das Konto zu Pay-As-You-Go – aber die F0-Speech-Stufe bleibt kostenlos, Monat für Monat, und alles in diesen Projekten passt bequem hinein. Wenn Sie überhaupt keine Karte angeben möchten und Student sind, gibt Ihnen die Option Azure für Studenten Guthaben ohne eine.

Es muss eine „Speech-Dienst“-Ressource sein. Ein Schlüssel von einer Translator-, Language- oder allgemeinen Cognitive-Services-Ressource sieht identisch aus und ist vollkommen gültig – aber jede Sprach-Anfrage gibt Fehler 401 zurück. Das hat uns beim Testen erwischt und eine Stunde gekostet. Wenn Sprache mit 401 fehlschlägt, während der Schlüssel richtig aussieht, überprüfen Sie, welche Art von Ressource Sie erstellt haben.

Was der Betrieb kostet

Sehr wenig, aber es ist nicht kostenlos, und Sie sollten ungefähr wissen, was Sie ausgeben, bevor Sie ein Projekt laufen lassen.

Service

Ungefähre Kosten

Azure Speech

Die kostenlose Stufe deckt etwa 5 Stunden Zuhören und 0,5 M Zeichen Sprechen pro Monat ab

DeepSeek

Ein Bruchteil eines Cents pro Antwort – tausende Antworten für ein paar Dollar

OpenAI Vision

Etwa ein oder zwei Cents pro Bild, je nach Modell

Preise ändern sich, also behandeln Sie diese als Richtwerte und nicht als Angebot. Jeder dieser Dienste hat eine Nutzungsseite, auf der Sie Ihre Ausgaben einsehen können, und alle ermöglichen es Ihnen, ein Ausgabenlimit festzulegen – was sich ab dem ersten Tag lohnt.

Halten Sie Ihre Schlüssel privat. Jeder, der sie hat, kann Ihr Geld ausgeben. Legen Sie sie nicht in ein Video, einen Screenshot, einen Forenbeitrag oder ein öffentliches Code-Repository. Wenn ein Schlüssel jemals offengelegt wird, löschen Sie ihn auf der Website des Anbieters und erstellen Sie einen neuen – das dauert Sekunden und ist die einzige echte Lösung.

Fehlerbehebung

Symptom

Ursache und Lösung

Bildschirm bleibt schwarz

Falsche GFX-Bibliotheksversion (1.5.6 verwenden) oder falsche Board-Einstellungen.

PSRAM alloc failed oder Kamera-Fehler 0xffffffff

Tools → PSRAM ist nicht auf OPI PSRAM eingestellt.

Nichts wird hochgeladen / kein COM-Port

Falscher USB-C-Anschluss oder der CH340-Treiber ist nicht installiert.

Kamera fällt aus und erholt sich nie

Die Reset-Leitung der Kamera ist mit der RESET-Taste des Boards verbunden, daher kann die Software sie nicht neu starten. Drücken Sie RESET. Wenn sie weiterhin ausfällt, setzen Sie das Kamera-Flachbandkabel neu ein.

Code herunterladen

Der vollständige Arduino-Sketch für dieses Projekt, zusammen mit pins.h und allem anderen, was benötigt wird, ist kostenlos herunterladbar.

Download 05_Vision_AI

Entpacken Sie es, öffnen Sie die .ino-Datei in der Arduino-IDE, überprüfen Sie die obigen Einstellungen und laden Sie es über den CH340K-USB-C-Anschluss hoch.

885-Arduin code for MaTouch AI ESP32S3 2.8in AI Camera: AI Vision
Sprache: C++
/*
 * ===========================================================================
 * 05_Vision_AI - MaTouch AI ESP32-S3 2,8" TFT ST7789V
 * ===========================================================================
 * 
 * ---------------------------------------------------------------------------
 * VIDEO UND SCHRIFTLICHE ANWEISUNGEN
 * 
 * Sieh dir das Video an: https://youtu.be/Px3FT47RV2M
 * Vollständiger Artikel: https://robojax.com/RTJ852
 * ("AI Vision" - Verkabelung, Einstellungen, Screenshots
 * und Antworten auf häufige Probleme)
 * 
 * Das Video zuerst anzusehen, spart dir Zeit - es zeigt die Einstellungen der Arduino IDE
 * und die Bibliotheksversionen, die Schritt für Schritt eingerichtet werden.
 * ---------------------------------------------------------------------------
 * 
 * Das Board SIEHT. Halte die Kamera auf etwas, drücke einen Knopf, und ein
 * OpenAI-Visionsmodell sagt dir, was es sieht - auf dem Bildschirm dargestellt
 * und (optional) über den Lautsprecher des Boards via Azure TTS wiedergegeben.
 * 
 * Zwei Modi, zwei Tasten:
 * 
 * OBJEKTE "Was siehst du?" - benennt die Dinge vor der Linse.
 * PERSON Beschreibt die Person im Bild: Brille, Ausdruck, was
 * sie tun. NUR BESCHREIBUNG - es wird nicht und darf nicht
 * versuchen zu sagen, WER jemand ist. Menschen anhand des Gesichts zu identifizieren,
 * verstößt gegen die Nutzungsrichtlinien von OpenAI, und das ist die richtige Entscheidung:
 * sag das im Video, es sind 15 ehrliche Sekunden wert.
 * 
 * WARUM OPENAI FÜR DIESE DEMO UND NICHT DEEPSEEK: Die API von DeepSeek ist nur textbasiert.
 * Sie kann überhaupt kein Bild akzeptieren. Azure OpenAI könnte es tun, aber reines
 * OpenAI ist ein Endpunkt ohne Bereitstellungssetup - am einfachsten zu folgen.
 * 
 * WIE DAS BILD REISET: Die OV3660 liefert uns direkt ein JPEG (800x600,
 * ~40 KB). Wir kodieren es in Base64 in PSRAM (~55 KB Text) und betten es in
 * die JSON-Anfrage als data: URI ein. Die 8 MB PSRAM machen dies trivial.
 * 
 * ---------------------------------------------------------------------------
 * FÜLLE secrets.h AUS, BEVOR DU FLASHST
 * (braucht WIFI_*, OPENAI_*; AZURE_* nur, wenn SPEAK_REPLIES 1 ist).
 * 
 * BOARD-EINSTELLUNGEN (Werkzeugmenü - JEDER Eintrag zählt, falsch = schwarzer Bildschirm
 * oder Compilerfehler. Diese werden zurückgesetzt, wenn du die Kerne wechselst - überprüfe sie erneut!)
 * 
 * Board: ESP32S3 Dev Modul
 * ESP32-Kern: 2.0.17
 * PSRAM: OPI PSRAM <-- erforderlich, Bildpuffer befinden sich dort
 * Flash-Größe: 16MB (128Mb)
 * Partition-Schema: 16M Flash (3MB APP/9.9MB FATFS)
 * USB CDC beim Booten: Deaktiviert <-- Lautsprecher teilt sich Pins mit nativen USB
 * Upload-Geschwindigkeit: 921600
 * Port: der CH340K USB-C Port (der in der Nähe von RESET)
 * 
 * BIBLIOTHEKEN
 * GFX-Bibliothek für Arduino v1.5.6 (NICHT 1.6.x - das passt zu Kern 3)
 * bb_captouch v1.3.1
 * ArduinoJson v7.x
 * Adafruit NeoPixel jede aktuelle Version
 * 
 * ---------------------------------------------------------------------------
 * FUNKTIONSBESCHREIBUNGEN IN DIESEM SKETCH
 * led(r,g,b) setze die RGB-Status-LED-Farbe
 * getTouch(&x,&y) lese das Touchpanel, gemappt auf Bildschirmkoordinaten
 * camStart(...) starte die Kamera in einem bestimmten Format/Größe
 * camStartPreview() starte die 240x240 RGB565 Live-View-Kamera
 * captureJpeg(&len) mache ein 800x600 JPEG in PSRAM (Kamera bleibt
 * danach AUS - Aufrufer startet die Vorschau neu)
 * readHttpResponse() lese eine HTTPS-Antwort und dekodiere sie richtig
 * askVision(...) sende Foto + Eingabeaufforderung an OpenAI, gib die Antwort zurück
 * spkInit() konfiguriere den I2S Lautsprecherausgang
 * readExact(...) lese genau N Bytes aus einer TLS-Verbindung
 * speak(text) Azure TTS -> lade Stimme nach PSRAM -> spiele sie ab
 * playLastAnswer() spiele die gespeicherte Stimme aus PSRAM ab (WIEDERHOLEN-Button)
 * drawButton(...) zeichne einen Seitenspaltenknopf
 * drawWifi() WiFi-Signalbalken + dBm-Anzeige
 * drawButtons() normaler Seitenspalte (die beiden Anfrage-Buttons)
 * drawClearSide() Antwortmodus-Seitenspalte (CLEAR + REPLAY)
 * showAnswer(text) Wortumwickelte Antwortüberlagerung auf dem Sucher
 * lookAndTell(...) ein vollständiger Zyklus: erfassen -> fragen -> anzeigen -> sprechen
 * setup() / loop() Startsequenz / Sucher + Touch-Verarbeitung
 * 
 * Robojax.com
 * ===========================================================================
 */
#define SPEAK_REPLIES  1 // 1 = Antworte laut vorlesen mit Azure TTS, 0 = nur Bildschirm

/*
 * Status-LED-Helligkeit, 0-255. Der WS2812 wird über die Stromversorgung betrieben und ist bei voller Leistung unangenehm hell - 25 ist auf der Kamera gut sichtbar.
 */
#define LED_BRIGHTNESS 15

/*
 * KAMERAORIENTIERUNG
 * 1 = Die Kamera zeigt in die SAME Richtung wie der Bildschirm (wie das Board geliefert wird) - das Bild ist gespiegelt, sodass es natürlich aussieht, wenn du es auf dich richtest.
 * 0 = Du hast das Band gefaltet, sodass das Objektiv vom Bildschirm weg zeigt (Telefonstil, Bildschirm zu dir / Kamera zum Motiv).
 * Wenn das Bild links-rechts umgekehrt aussieht, drehe diese Zahl um.
 */
#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);

/*
 * --- die zwei visuellen Aufforderungen ------------------------------------------------
 * Halten Sie die Antworten kurz: Sie müssen auf einen 320x240 Bildschirm passen und, wenn sie gesprochen werden, dürfen sie den Präsentator nicht unangenehm lange warten lassen.
 */
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.";

/*
 * --- layout: Sucher links, Tasten rechts ------------------------------- */
 * #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(); }
 * 
 * /* ===========================================================================
 *  Berührung
 * ===========================================================================
 */
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;
}


/*
 * Kamera - RGB565 für die Liveansicht; die JPEG-Aufnahme erfolgt durch einen Neustart des Treibers, dieselbe Technik wie bei Skizze 02.
 */
bool mirrored = CAMERA_FACES_USER; // siehe die Definition oben in der Datei

/*
 * Der Sucher ist AUS, während ein Cloud-Anruf läuft und die Antwort auf dem Bildschirm angezeigt wird. Zwei Gründe, beide am Tisch gelernt: 
 * 1. RAM: der Live-Kamera-Treiber verbraucht den internen Speicher, den ein TLS-Handshake benötigt - bei aktivem Vorschaubild schlägt die Verbindung zu OpenAI fehl (HTTP -1).
 * 2. Lesbarkeit: der Sucher aktualisiert sich 25x/s und würde die Antwort überdecken. Die Antwort bleibt auf dem Bildschirm, bis der Benutzer auf LÖSCHEN tippt - kein Timer.
 */
bool preview_on = false;

/*
 * Die letzte gesprochene Antwort wird im PSRAM BEHAUPTET, sodass die WIEDERHOLEN-Taste sie erneut abspielen kann, ohne einen weiteren Cloud-Anruf. Überschrieben durch die nächste Antwort.
 */
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;
/*
 * Teilen Sie den I2C-Bus, den Wire bereits steuert (das Touchpanel befindet sich dort ebenfalls), anstatt die Kamera einen zweiten Treiber auf denselben Pins installieren zu lassen - das tötet den Touch. Wire.begin() muss vor dieser Funktion ausgeführt werden.
 */
  c.pin_sccb_sda = -1;
  c.pin_sccb_scl = -1;
  c.sccb_i2c_port = 0; // Draht = I2C-Port 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); }

/*
 * Fangen Sie ein SVGA JPEG in einem PSRAM-Puffer ein, der dem Aufrufer gehört. */
 * uint8_t *captureJpeg(size_t *len_out) {
 *  esp_camera_deinit();
 *  delay(120);
 *  if (!camStart(PIXFORMAT_JPEG, FRAMESIZE_SVGA, 1, 12)) { *len_out = 0; return nullptr; }
 * 
 *  // einige Aufwärm-Frames, damit sich die Belichtung stabilisiert
 *  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 *copy = nullptr;
 * len_out = 0;
 *  camera_fb_t *fb = esp_camera_fb_get();
 *  if (fb && fb->len > 0) {
 *  copy = (uint8_t *)ps_malloc(fb->len);
 *  if (copy) { memcpy(copy, fb->buf, fb->len); *len_out = fb->len; }
 *  }
 *  if (fb) esp_camera_fb_return(fb);
 * 
 *  /* Absichtlich die Kamera hier AUS lassen. Der Aufrufer startet die Vorschau
 * nach dem Cloud-Aufruf - ein laufender Kameratreiber entzieht dem TLS-Handschlag
 * den internen RAM (das war der "Vision HTTP -1" Fehler).
 */
  esp_camera_deinit();
  delay(120);
  return copy;
}


/*
 * HTTP-Antwortleser - geteilt durch die nachstehenden Cloud-Aufrufe. Gibt den Statuscode zurück und füllt body_out. Handhabt die chunked Übertragungscodierung RICHTIG: Die Chunk-Größenmarker müssen entfernt werden, da sie sonst in das JSON eingebettet werden und das Parsen fehlschlägt. (OpenAI gibt seine Antworten schön formatiert aus, sodass sie sich über mehrere Chunks erstrecken - das hat uns am Bench getroffen.)
 */
/*
 * Blockiere, bis die Verbindung Daten hat (oder das Budget aufgebraucht ist). Jeder nachfolgende Lesevorgang durchläuft dies, da der Server viele Sekunden lang ruhig sein kann, während er das Bild analysiert – und ein einfaches read() würde einfach ablaufen.
 */
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; // leere Zeile = Ende der Kopfzeilen
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
  }

  if (chunked) {
    int blanks = 0;
    while (true) {
/*
 * Hier zu warten – anstatt read() ablaufen zu lassen – ist essentiell: ein abgelaufener Lesevorgang sieht genau wie "0" (letzter Teil) aus, was den Körper stillschweigend auf nichts kürzt, während der Server noch nachdenkt.
 */
      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; // echter letzter Bereich

      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;
}


/*
 * ===========================================================================
 *  OpenAI Vision-Anruf
 *  Der Anforderungstext wird manuell im PSRAM erstellt, anstatt durch ArduinoJson,
 *  da das Einbetten eines 55 KB großen Base64-Strings in ein JSON-Dokument bedeuten würde,
 *  dass zwei Kopien gehalten werden müssen. Base64-Text benötigt niemals JSON-Escaping, daher ist das sicher.
 * ===========================================================================
 */
bool askVision(const uint8_t *jpg, size_t jpg_len, const char *prompt, String &answer_out) {
 // --- Bild in PSRAM base64 codieren ---
  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;
  }

 // --- die JSON-Anfrage darum herum erstellen ---
/*
 * Aktuelle OpenAI-Modelle lehnen den alten Namen "max_tokens" ab - es muss "max_completion_tokens" sein (gegen die live API überprüft, Juli 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);

 // --- sende es: manuelle HTTP, stückweise Upload (bewährtes Muster aus 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;
  }

/*
 * Lies die Antwort mit ordentlichem De-Chunken */
 *  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: JSON-Parsing fehlgeschlagen, %u Bytes empfangen\n", resp.length());
 *  }
 *  return ok;
 * }
 * 
 * /* ===========================================================================
 *  Azure TTS - derselbe Streaming-Trick wie Muster 04: rohes PCM in 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);
}

/*
 * Genau n Bytes von einem Client lesen (oder bis zum Timeout). */
 * 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(); // unten definiert; explizites Prototyp für die 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>";
 * 
 *  /* Manuelles HTTP mit ordnungsgemäßer Dekodierung: Azure sendet diesen Audio in Chunk-Form, und
 * HTTPClient's roher Stream leckt die ASCII Chunk-Größenzeilen in das PCM -
 * jede von ihnen spielt sich als hörbarer KLAPPE ab.
 */
  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) {
/*
 * behalte diese Antwort für die WIEDERHOLEN-Taste (ersetze die vorherige), dann spiele sie ab
 */
    if (last_audio) free(last_audio);
    last_audio = audio;
    last_audio_len = alen;
    playLastAnswer();
  } else {
    free(audio);
  }
}

/*
 * Gespielte letzte Antwort aus PSRAM - direkt nach dem Download und von REPLAY verwendet. */
 * void playLastAnswer() {
 *  if (!last_audio || last_audio_len <= WAV_HEADER_LEN) return;
 *  static const uint8_t lead_in[640] = {0}; // 20 ms Stille vorab
 *  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);
}

/*
 * WiFi-Balken + dBm am unteren Ende der Seitenleiste, aktualisiert aus der Schleife */
 * void drawWifi() {
 *  gfx->fillRect(BTN_X, 188, BTN_W, 52, SCHWARZ);
 *  bool up = (WiFi.status() == WL_CONNECTED);
 *  long rssi = up ? WiFi.RSSI() : -100;
 *  int bars = 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 < bars) ? GRÜN : 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(CYAN);
 *  gfx->printf("%ld", rssi);
 *  } else {
 *  gfx->setTextColor ROT;
 *  gfx->print("DOWN");
 *  }
 *  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("WiFi-Signal");
 * }
 * 
 * /* normale Seitenleiste: die beiden Nachfragen-Buttons
 */
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();
}

/*
 * Antwortmodus Seitenleiste: LÖSCHEN (zurück zur Kamera) + WIEDERHOLEN (nochmals sagen). Die Antwort bleibt auf dem Bildschirm, bis LÖSCHEN berührt wird.
 */
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();
}

/*
 * Wortumbruch-Antwortüberlagerung am unteren Ende des Suchers. */
 * 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)));
 *  }
 * }
 * 
 * /* ===========================================================================
 *  Ein vollständiger "sehen und sagen" Zyklus
 * ===========================================================================
 */
void lookAndTell(const char *prompt, const char *label) {
  led(255, 120, 0); // bernstein: arbeiten
  preview_on = false; // Kamera geht hier AUS

  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 ersetzt die Nachfrage-Tasten

#if SPEAK_REPLIES
  led(0, 255, 40); // grün: sprechen
  speak(answer); // Antwort bleibt auf dem Bildschirm
#endif
  led(0, 0, 0);
/*
 * Die Antwort bleibt jetzt, bis der Benutzer CLEAR antippt.
 */
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);

 // Der SCCB der Kamera teilt sich diesen Bus, daher muss das Wire vor der Kamera aktiviert werden.
  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.");
}


/*
 * SCHLEIFE
 */
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); // Antwort auf dem Bildschirm, warten auf KLAR
  }

/*
 * live WiFi-Signal, alle 2 s in beiden Modi aktualisiert */
 *  static uint32_t last_wifi = 0;
 *  if (millis() - last_wifi > 2000) {
 *  last_wifi = millis();
 *  drawWifi();
 *  }
 * 
 *  /* Kanten-erfasster Touch: Eine Aktion wird nur bei einem NEUEN Fingerdruck ausgelöst, und der
 * Finger muss vollständig angehoben werden (4 aufeinanderfolgende leere Abfragen), bevor erneut etwas
 * ausgelöst werden kann. Dies verhindert, dass ein Tipp auf LÖSCHEN auch den
 * Aufnahmeknopf auslöst, der einen Moment später an derselben Stelle erscheint.
 */
  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; // neuer Tipp - einmalig ausführen

      if (!preview_on) {

Dateien📁

Erforderliche Datei (.h)

  • secrets.h
    Datei für das Makerfabs MaTouch AI ESP32S3 2.8" TFT-Kameramodul
    secrets.h 0.01 MB

Andere Dateien

  • pins.h
    Pins-Datei für MaTouch AI ESP32S3 2.8" Kamera-LCD-Touchscreen.
    pins.h 0.01 MB

Schaltplan

  • MaTouch_AI 2.8“ MaTouch AI ESP32S3 2.8" TFT ST7789V Schaltplan
    Das neueste MaTouch AI Board integriert I2S-Spracheingang/I2S-Lautsprecher/ 3-Millionen-Kamera OV3660/ 320*240 Auflösungsdisplay, mit ESP32S3 starkem Prozessor & Wifi-Fähigkeit, um dieses Board zu einem guten Werkzeug/Plattform für KI-Entwicklung mit ESP32 zu machen.
    MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF 0.15 MB