Die klassische Fotofalle mit der ESP32-CAM hat einen kleinen Haken: Sie schnappt bei jeder Bewegung zu. Ein vorbeiziehender Ast, deine Katze, das wechselnde Licht am Abend – schon landet wieder ein Foto auf deinem Smartphone, das dich eigentlich gar nicht interessiert.
In diesem Projekt baust du deine Fotofalle deshalb zu einer KI-Fotofalle aus. Sobald der Bewegungssensor auslöst, nimmt die ESP32-CAM ein Foto auf – und lässt es von OpenAI analysieren. Nur wenn auf dem Bild tatsächlich das zu sehen ist, was dich interessiert (zum Beispiel ein Vogel an deiner Vogeltränke), schickt dir dein Board das Foto per Telegram. Andernfalls wird es verworfen.
Dieses Projekt baut direkt auf drei anderen Tutorials hier bei Pollux Labs auf. Wenn du sie noch nicht kennst, wirf am besten vorab einen Blick hinein – hier bekommst du nur die wichtigsten Schritte zusammengefasst:
- Die Fotofalle mit ESP32-CAM und Telegram – Aufbau, Verkabelung und der Telegram-Bot
- OpenAI und Claude mit dem ESP32 nutzen – dein API-Key und die Grundlagen der KI-Kommunikation
- So erstellst du einen Telegram-Bot – Infos, wie du Telegram vorbereitest
Diese Bauteile brauchst du
- 1× ESP32-CAM (AI Thinker) Idealerweise mit PSRAM; Kamera ist auf dem Board integriert.
- 1× MB-Board
- 1× PIR-Bewegungssensor HC-SR501
- 1× Jumper-Kabel Dupont Für die Verbindung von ESP32-CAM, PIR-Sensor und ggf. der IO0-GND-Programmierbrücke.
- 1× (optional) FTDI-Adapter USB-Seriell
Affiliate-Links: Kaufst du über diese Links, erhält Pollux Labs eine kleine Provision – für dich ohne Mehrkosten.
Dazu brauchst du einen Telegram-Bot (Token und Chat-ID) sowie einen API-Key von OpenAI. Wie du beides einrichtest, erfährst du in den oben verlinkten Tutorials oben.
Der Aufbau: identisch zur klassischen Fotofalle
Am Aufbau ändert sich gegenüber der klassischen Fotofalle nichts – die ganze Intelligenz steckt diesmal allein im Code. Du verbindest also wieder deinen PIR-Sensor mit der ESP32-CAM und versorgst das Board über den FTDI-Adapter (oder das MB-Programmierboard) mit Strom.
Hier noch einmal die Verbindungen übersichtlich dargestellt:
| FTDI-Adapter | ESP32-CAM |
| GND | GND |
| VCC | 5V |
| TXD | U0R |
| RXD | U0T |
| HC-SR501 | ESP32-CAM |
| VCC | 3v3 |
| GND | GND |
| OUT | IO13 |
Hinweis: Falls du einen FTDI-Adapter nutzt: Denke daran, für den Upload eines Sketches IO0 mit GND zu verbinden (die Brücke) und das Board danach mit dem RESET-Button neu zu starten. Trenne die Brücke erst wieder, wenn der Sketch hochgeladen ist.
___STEADY_PAYWALL___
Warum hier nicht ESPAI, sondern die OpenAI-API direkt?
Im Grundlagen-Tutorial zu OpenAI haben wir die komfortable Bibliothek ESPAI verwendet. Sie eignet sich hervorragend für Text – kann aktuell aber noch keine Bilder an die Modelle senden (Bildeingaben stehen dort erst auf der Roadmap). Genau das brauchen wir hier aber.
Deshalb sprechen wir in diesem Projekt die Vision-API von OpenAI direkt an. Klingt aufwändiger, als es ist: Wir kodieren das aufgenommene Foto in Base64, verpacken es in eine JSON-Anfrage und schicken sie an OpenAI. Das übernimmt im Sketch eine einzige Funktion namens motivErkannt() – du musst dich darum später nicht mehr kümmern.
So funktioniert die KI-Fotofalle
Der Ablauf im Sketch ist schnell erklärt:
- Der PIR-Sensor erkennt eine Bewegung.
- Die ESP32-CAM nimmt ein Foto auf.
- Das Foto wird an OpenAI geschickt – mit der Frage, ob dein gesuchtes Motiv darauf zu sehen ist.
- Antwortet die KI mit JA, sendet dir das Board das Foto per Telegram.
- Antwortet sie mit NEIN, wird das Foto verworfen – du bekommst keine Nachricht.
Damit die Analyse günstig und schnell bleibt, nehmen wir das Foto in VGA-Auflösung (640×480) auf. Für die Frage „Ist da ein Vogel?“ ist das mehr als ausreichend – OpenAI skaliert größere Bilder ohnehin herunter, und kleinere Bilder bedeuten weniger Tokens und damit geringere Kosten.
Deine Zugangsdaten in einer secrets.h
Wie im OpenAI-Tutorial lagern wir alle Zugangsdaten in eine separate Datei aus. Lege dazu im selben Ordner wie den Sketch eine Datei namens secrets.h an und trage deine Daten ein:
#ifndef SECRETS_H
#define SECRETS_H
// WLAN
const char* WIFI_SSID = "dein-WLAN-Name";
const char* WIFI_PASSWORD = "dein-WLAN-Passwort";
// OpenAI-Key von https://platform.openai.com/api-keys
const char* OPENAI_API_KEY = "sk-...";
// Telegram-Bot (Token vom BotFather, Chat-ID von deinem Bot)
const char* TELEGRAM_BOT_TOKEN = "123456789:ABC-DEF...";
const char* TELEGRAM_CHAT_ID = "123456789";
#endif
Die benötigten Bibliotheken
Für die Kommunikation mit Telegram nutzen wir wieder UniversalTelegramBot (von Brian Lough) samt der Abhängigkeit ArduinoJson. Beide installierst du bequem über den Bibliotheksverwalter der Arduino IDE (Menü Werkzeuge -> Bibliotheken verwalten).
Für die Bildanalyse brauchst du keine zusätzliche Bibliothek: Die Base64-Kodierung übernimmt mbedtls, das bereits Teil des ESP32-Cores ist. Du musst also nichts weiter installieren.
Das kannst du im Code anpassen
Wonach gesucht wird. Das Herzstück des Projekts ist eine einzige Zeile ganz oben im Sketch:
String detektionsZiel = "ein Vogel";
Hier legst du fest, worauf deine Fotofalle achten soll. Schreibe hier zum Beispiel „eine Katze“, „ein Paketbote“ oder „ein Auto“ hinein – der Rest des Codes bleibt gleich. Aus dieser Angabe baut der Sketch automatisch die passende Frage an OpenAI zusammen: „Ist auf diesem Bild eindeutig … zu sehen? Antworte nur mit JA oder NEIN.“
Das Modell. Standardmäßig nutzen wir gpt-4.1-mini – ein günstiges Modell, das Bilder versteht und für diese einfache Ja/Nein-Frage völlig ausreicht. Möchtest du ein anderes bildfähiges Modell testen, änderst du einfach diese Zeile:
const char* OPENAI_MODEL = "gpt-4.1-mini";
Die Ruhezeit. Wie bei der klassischen Fotofalle findest du am Ende des loop() einen delay(20000) von 20 Sekunden. Das ist die Pause nach jeder Auswertung. Beachte: Anders als früher entstehen dir bei jeder Auslösung nun kleine Kosten für die OpenAI-Anfrage. Eine großzügige Ruhezeit hält daher nicht nur die Zahl der Nachrichten, sondern auch die Kosten niedrig.
Der komplette Sketch
Hier nun der gesamte Sketch zum Rauskopieren. Achte darauf, dass die Datei secrets.h im selben Ordner liegt:
/*
KI-Fotofalle mit ESP32-CAM, OpenAI Vision und Telegram
------------------------------------------------------
Basiert auf der Fotofalle von Pollux Labs (Telegram-Versand nach einem
Sketch von Rui Santos / Random Nerd Tutorials) und ergaenzt diese um
eine Bildanalyse mit der Vision-API von OpenAI.
Ablauf:
PIR-Sensor loest aus -> Foto aufnehmen -> von OpenAI pruefen lassen,
ob das gesuchte Motiv (z.B. ein Vogel) zu sehen ist -> wenn JA per
Telegram senden, wenn NEIN verwerfen.
Adaptiert von Pollux Labs - https://polluxlabs.net
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include "esp_camera.h"
#include <UniversalTelegramBot.h>
#include "mbedtls/base64.h"
#include "secrets.h"
// Wonach soll auf dem Foto gesucht werden? Beispiele:
// "ein Vogel", "eine Katze", "ein Paketbote", "ein Auto"
String detektionsZiel = "ein Vogel";
// OpenAI-Modell mit Bildverstaendnis (guenstig und schnell)
const char* OPENAI_MODEL = "gpt-4.1-mini";
// Telegram (Werte kommen aus secrets.h)
String BOTtoken = TELEGRAM_BOT_TOKEN;
String CHAT_ID = TELEGRAM_CHAT_ID;
// Ein einziger sicherer Client fuer beide Dienste (nacheinander benutzt)
WiFiClientSecure clientTCP;
UniversalTelegramBot bot(BOTtoken, clientTCP);
// Pin des Bewegungssensors
#define PIR_PIN 13
// CAMERA_MODEL_AI_THINKER
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
void configInitCamera() {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
// VGA (640x480) reicht fuer die KI-Analyse voellig aus, spart Speicher
// und Tokens. Bei Bedarf kannst du hoeher gehen (z.B. FRAMESIZE_SVGA).
config.frame_size = FRAMESIZE_VGA;
config.jpeg_quality = 12; // 0-63, kleiner = besser
config.fb_count = 1;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
delay(1000);
ESP.restart();
}
}
// Schickt das Foto an OpenAI und fragt, ob das gesuchte Motiv zu sehen ist.
// Gibt true zurueck, wenn OpenAI mit JA antwortet.
bool motivErkannt(camera_fb_t * fb) {
// 1) Bild als Base64 kodieren - moeglichst im PSRAM
size_t b64Len = 4 * ((fb->len + 2) / 3);
unsigned char * b64Buf = (unsigned char *) ps_malloc(b64Len + 1);
if (!b64Buf) b64Buf = (unsigned char *) malloc(b64Len + 1);
if (!b64Buf) {
Serial.println("Kein Speicher fuer die Base64-Kodierung");
return false;
}
size_t olen = 0;
mbedtls_base64_encode(b64Buf, b64Len + 1, &olen, fb->buf, fb->len);
// 2) Verbindung zu OpenAI aufbauen
clientTCP.setInsecure(); // der Einfachheit halber ohne Zertifikatspruefung
const char* host = "api.openai.com";
if (!clientTCP.connect(host, 443)) {
Serial.println("Verbindung zu OpenAI fehlgeschlagen");
free(b64Buf);
return false;
}
// 3) JSON-Request zusammenbauen (Kopf + Base64-Bild + Schluss)
String prompt = "Ist auf diesem Bild eindeutig " + detektionsZiel +
" zu sehen? Antworte nur mit JA oder NEIN.";
String bodyHead = String("{\"model\":\"") + OPENAI_MODEL +
"\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":[" +
"{\"type\":\"text\",\"text\":\"" + prompt + "\"}," +
"{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/jpeg;base64,";
String bodyTail = "\"}}]}]}";
size_t contentLength = bodyHead.length() + olen + bodyTail.length();
// 4) Request senden - das Base64-Bild in Haeppchen, um RAM zu sparen
clientTCP.println("POST /v1/chat/completions HTTP/1.1");
clientTCP.println("Host: " + String(host));
clientTCP.println("Authorization: Bearer " + String(OPENAI_API_KEY));
clientTCP.println("Content-Type: application/json");
clientTCP.println("Content-Length: " + String(contentLength));
clientTCP.println("Connection: close");
clientTCP.println();
clientTCP.print(bodyHead);
for (size_t i = 0; i < olen; i += 1024) {
size_t chunk = (i + 1024 < olen) ? 1024 : (olen - i);
clientTCP.write(b64Buf + i, chunk);
}
clientTCP.print(bodyTail);
free(b64Buf);
// 5) Antwort einlesen (bis der Server die Verbindung schliesst)
String antwort = "";
long startTimer = millis();
while ((millis() - startTimer < 15000) &&
(clientTCP.connected() || clientTCP.available())) {
while (clientTCP.available()) {
antwort += (char) clientTCP.read();
startTimer = millis();
}
delay(10);
}
clientTCP.stop();
// 6) Antworttext aus dem JSON herausziehen ("content":"JA"/"NEIN")
String inhalt = "";
int idx = antwort.indexOf("\"content\"");
if (idx >= 0) {
int q1 = antwort.indexOf('"', antwort.indexOf(':', idx) + 1);
int q2 = antwort.indexOf('"', q1 + 1);
if (q1 > 0 && q2 > q1) inhalt = antwort.substring(q1 + 1, q2);
}
inhalt.toUpperCase();
Serial.println("OpenAI-Antwort: " + inhalt);
return inhalt.indexOf("JA") >= 0;
}
// Sendet das bereits aufgenommene Foto an deinen Telegram-Bot.
void sendPhotoTelegram(camera_fb_t * fb) {
const char* myDomain = "api.telegram.org";
String getAll = "";
String getBody = "";
clientTCP.setCACert(TELEGRAM_CERTIFICATE_ROOT); // Zertifikat fuer Telegram
Serial.println("Connect to " + String(myDomain));
if (clientTCP.connect(myDomain, 443)) {
Serial.println("Connection successful");
String head = "--RandomNerdTutorials\r\nContent-Disposition: form-data; name=\"chat_id\"; \r\n\r\n" + CHAT_ID + "\r\n--RandomNerdTutorials\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"esp32-cam.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--RandomNerdTutorials--\r\n";
size_t imageLen = fb->len;
size_t extraLen = head.length() + tail.length();
size_t totalLen = imageLen + extraLen;
clientTCP.println("POST /bot" + BOTtoken + "/sendPhoto HTTP/1.1");
clientTCP.println("Host: " + String(myDomain));
clientTCP.println("Content-Length: " + String(totalLen));
clientTCP.println("Content-Type: multipart/form-data; boundary=RandomNerdTutorials");
clientTCP.println();
clientTCP.print(head);
uint8_t *fbBuf = fb->buf;
size_t fbLen = fb->len;
for (size_t n = 0; n < fbLen; n = n + 1024) {
if (n + 1024 < fbLen) {
clientTCP.write(fbBuf, 1024);
fbBuf += 1024;
}
else if (fbLen % 1024 > 0) {
size_t remainder = fbLen % 1024;
clientTCP.write(fbBuf, remainder);
}
}
clientTCP.print(tail);
int waitTime = 10000; // timeout 10 seconds
long startTimer = millis();
boolean state = false;
while ((startTimer + waitTime) > millis()) {
Serial.print(".");
delay(100);
while (clientTCP.available()) {
char c = clientTCP.read();
if (state == true) getBody += String(c);
if (c == '\n') {
if (getAll.length() == 0) state = true;
getAll = "";
}
else if (c != '\r')
getAll += String(c);
startTimer = millis();
}
if (getBody.length() > 0) break;
}
clientTCP.stop();
Serial.println(getBody);
}
else {
Serial.println("Connected to api.telegram.org failed.");
}
}
void setup() {
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); // Brownout-Detektor aus
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
configInitCamera();
WiFi.mode(WIFI_STA);
Serial.print("Connecting to ");
Serial.println(WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
}
Serial.println();
Serial.println("WLAN verbunden - warte auf Bewegung");
}
void loop() {
if (digitalRead(PIR_PIN)) {
Serial.println("Bewegung erkannt - nehme Foto auf");
camera_fb_t * fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Aufnahme fehlgeschlagen");
delay(1000);
return;
}
if (motivErkannt(fb)) {
Serial.println(detektionsZiel + " erkannt - sende Foto an Telegram");
sendPhotoTelegram(fb);
} else {
Serial.println("Motiv nicht erkannt - Foto verworfen");
}
esp_camera_fb_return(fb);
delay(20000); // Ruhezeit nach einer Auswertung
}
}
Lade den Sketch nun wie gewohnt auf deine ESP32-CAM (mit FTDI-Adapter Brücke für den Upload setzen, danach trennen und RESET drücken). Öffne anschließend den Seriellen Monitor mit 115200 Baud. Nach der WLAN-Verbindung wartet das Board auf eine Bewegung. Sobald der PIR-Sensor auslöst, siehst du im Monitor, wie das Foto an OpenAI geschickt wird – und ob die Antwort JA oder NEIN lautet. Nur im ersten Fall landet das Bild in deinem Telegram-Chat.
Kosten, Grenzen und Datenschutz
Kosten. Jede Auslösung schickt genau eine Anfrage mit einem kleinen VGA-Bild an OpenAI. Mit gpt-4.1-mini bewegst du dich dabei im Bereich von Bruchteilen eines Cents pro Auswertung. Trotzdem gilt: Steht deine Falle an einer stark frequentierten Stelle, summiert sich das. Die Ruhezeit im loop() und ein gut eingestellter PIR-Sensor helfen, unnötige Anfragen zu vermeiden. Die aktuellen Preise findest du in der Preisliste von OpenAI.
Genauigkeit. Die KI liegt nicht immer richtig. Je nach Licht, Bildausschnitt und Motiv kann es zu Fehleinschätzungen kommen. Formuliere dein detektionsZiel möglichst eindeutig und experimentiere ein wenig – oft hilft schon eine etwas präzisere Beschreibung („ein Vogel an einer Vogeltränke“ statt nur „ein Vogel“).
Datenschutz. Anders als bei der rein lokalen Fotofalle verlässt hier jedes ausgelöste Foto dein Netzwerk und wird an OpenAI übertragen. Setze die KI-Fotofalle deshalb nur dort ein, wo du keine Persönlichkeitsrechte verletzt, und mache dir vorab Gedanken über den Umgang mit den aufgenommenen Bildern. Dieses Projekt dient dem Ausprobieren – für einen dauerhaften, produktiven Einsatz solltest du Sicherheit und Datenschutz gründlich bedenken.
Mögliche Fehler
Siehst du im Seriellen Monitor die Meldung Verbindung zu OpenAI fehlgeschlagen, prüfe deine WLAN-Verbindung und ob dein API-Key in der secrets.h korrekt hinterlegt ist. Erscheint OpenAI-Antwort: ohne JA oder NEIN dahinter, hat die Anfrage nicht funktioniert – häufig steckt dann ein ungültiger oder abgelaufener Key oder ein aufgebrauchtes Guthaben dahinter. Kontrolliere dein Konto unter platform.openai.com.
Meldet der Monitor Kein Speicher für die Base64-Kodierung, fehlt deiner ESP32-CAM PSRAM oder er ist nicht aktiviert. Die meisten AI-Thinker-Boards haben PSRAM – stelle sicher, dass in der Arduino IDE unter Werkzeuge die passende PSRAM-Option aktiviert ist. Alternativ kannst du im Sketch eine kleinere Auflösung wählen (z.B. FRAMESIZE_QVGA).
Alle übrigen Stolpersteine rund um Upload, Brownout und Verkabelung sind dieselben wie bei der klassischen Fotofalle – schau bei Problemen dort noch einmal in den Abschnitt „Mögliche Fehler“.
Fazit
Mit wenigen Zeilen zusätzlichem Code ist aus deiner Fotofalle ein kleiner, aufmerksamer Wächter geworden, der selbst entscheidet, was eine Nachricht wert ist. Von hier aus sind viele Varianten denkbar: Lass dir statt eines schlichten JA/NEIN gleich eine kurze Beschreibung der Szene schicken, reagiere unterschiedlich auf verschiedene Motive oder kombiniere die Falle mit weiteren Sensoren. Viel Spaß beim Ausbauen!