Meine smarte DIY-Türklingel: Warum der ESP32-S3 an der Kamera verzweifelt (und meine echten Alternativen)
Ich glaube, jeder Bastler und Smart-Home-Fan kennt diesen Traum: Eine eigene, smarte Türklingel bauen. Ohne nervigen Cloud-Zwang, ohne monatliche Abos und mit 100 % Datenkontrolle im eigenen Heimnetz. Für mich war das Thema „Zero-Knowledge“ besonders wichtig. Niemand – absolut niemand – soll Zugriff auf die Bilder vor meiner Haustür haben.
Mein Favorit für das Gehirn des Projekts war schnell gefunden: Ein ESP32-S3 Board. Genug Power auf dem Papier, Dual-Core-Prozessor, integriertes WLAN und direkte Anschlüsse für Kamera-Module wie die OV2640 oder OV3660.
Klingt nach dem perfekten Setup, oder? Tja. Nach wochenlangem Basteln, Fluchen und C++-Code-Optimieren bis in die tiefe Nacht muss ich heute ein ehrliches Fazit ziehen. Für eine reine Audio-Klingel oder ein paar Sensoren ist der kleine Chip ein absolutes Biest. Aber sobald eine Kamera und verschlüsselte Push-Nachrichten ins Spiel kommen, rennt man gegen eine unsichtbare, physikalische Wand.
Hier ist die ungeschönte Wahrheit über mein Projekt – und warum ich am Ende kapitulieren musste.
Der Kampf um jede Millisekunde
Glaubt mir, ich habe wirklich alles versucht. Um die Latenz zwischen „Knopf wird gedrückt“ und „Bild ist auf dem Handy“ zu drücken, flog alles an Ballast aus dem Code raus. Permanente Video-Streams? Weg damit. Stattdessen absolute Fokussierung auf das Event: Jemand klingelt -> Foto schießen -> per NTFY ans Handy pushen und den Android TV anpingen.
Ich habe das Betriebssystem des Chips (FreeRTOS) bis ans Limit ausgereizt. Kern 1 durfte sich exklusiv um den I2S-Klingelton kümmern, während Kern 0 das WLAN und die Bilder übernahm. Als das RAM extrem knapp wurde, habe ich den Code sogar so umgeschrieben, dass die riesigen JPEG-Dateien in den externen PSRAM gezwungen wurden. So bekam der interne Speicher wieder Luft zum Atmen.
Das sah im Code dann ungefähr so aus:
C++
// Statt das interne RAM zu verstopfen...
// uint8_t* jpegCopy = (uint8_t*)malloc(fb->len);
// ...habe ich das Bild rigoros in den externen PSRAM verbannt:
uint8_t* jpegCopy = (uint8_t*)ps_malloc(fb->len);
Das hat der internen Verarbeitung extrem viel gebracht. Aber es reichte am Ende einfach immer noch nicht.
Die drei Endgegner des ESP32-S3
Egal, wie perfekt der Code am Ende war, drei Hardware- und Netzwerk-Probleme haben mich einfach in die Knie gezwungen:
- Der Verschlüsselungs-Albtraum (TLS/SSL): Wenn man Push-Dienste aus dem Internet nutzt, muss das Bild über eine sichere HTTPS-Verbindung laufen. Der ESP32 hat zwar einen Hardware-Krypto-Chip, aber allein der Handshake mit den Servern dauert über das Internet gefühlte Ewigkeiten. Resultat: teilweise 3 bis 6 Sekunden Verzögerung. In der Zeit hat der Postbote schon längst den Zettel in den Briefkasten geworfen und sitzt wieder im Auto.
- Das Speicher-Tetris: Die Kamera feuert ein relativ großes Bild in den Speicher, gleichzeitig fordert die HTTPS-Verschlüsselung knapp 40 KB am Stück an. Der kleine Controller ist pausenlos damit beschäftigt, Speicherblöcke hin- und herzuschieben.
- Der unsichtbare „Brownout“: Kamera an, WLAN funkt auf maximaler Stufe, der Audio-Verstärker plärrt los – zack, zieht das kleine Board für den Bruchteil einer Sekunde fast 1 Ampere Strom. Bei typischen Maker-Netzteilen oder auf dem Steckbrett bricht da sofort die Spannung ein und der Chip startet sich heimlich neu.
Mein Fazit: Kaufen oder richtig bauen?
Man muss einfach irgendwann einsehen, wofür ein Mikrocontroller gemacht ist. Hardware-Sensoren auslesen? Perfekt. Relais schalten? Ein Traum. Aber hochauflösende JPEGs komprimieren und mit komplexer Verschlüsselung ins Netz jagen? Da hört der Spaß auf.
Wenn ihr heute vor der Frage steht, wie ihr eure Haustür smart macht, gebe ich euch zwei Ratschläge aus leidvoller Erfahrung:
Option 1: Ihr wollt, dass es einfach nur sofort läuft? Schluckt den Stolz und kauft ein fertiges System von Reolink oder Eufy. Die haben auf ihren Platinen spezielle Hardware-Chips verbaut, die absolut nichts anderes tun, als Videos blitzschnell zu encodieren. Das funktioniert Out-of-the-Box und ohne diese spürbaren Sekunden an Verzögerung.
Option 2: Ihr wollt zwingend selber bauen (Zero-Knowledge, keine Cloud)? Dann vergesst Mikrocontroller für Videogeschichten. Holt euch für das Projekt einen echten Single-Board-Computer wie den Orange Pi oder einen Raspberry Pi. Dort habt ihr ein richtiges Linux, Gigabytes an Arbeitsspeicher (Speicherprobleme ade!) und vor allem dedizierte Hardware-Encoder für Videostreams. Das ist die einzige echte Basis für eine saubere, smarte DIY-Klingel mit Kamera, die in Echtzeit reagiert.
Den ESP32-S3 hebe ich mir jetzt für mein nächstes Projekt auf – vielleicht eine Wetterstation. Dafür ist er nämlich wirklich unschlagbar.
Hier der letzte optimierte Code für den Freenove ESP32S3
/*
* ════════════════════════════════════════════════════════════
* SmartBell V18.9 ALPHA (TV Text-Only & Max Performance)
* ════════════════════════════════════════════════════════════
*/
#include "esp_camera.h"
#include "esp_http_server.h"
#include <esp_wifi.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <ESPAsyncWebServer.h>
#ifndef ELEGANTOTA_USE_ASYNC_WEBSERVER
#define ELEGANTOTA_USE_ASYNC_WEBSERVER 1
#endif
#include <ElegantOTA.h>
#include <Preferences.h>
#include "FS.h"
#include "SD_MMC.h"
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include "Audio.h"
// --- FREENOVE S3 PINOUT ---
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 15
#define SIOD_GPIO_NUM 4
#define SIOC_GPIO_NUM 5
#define Y9_GPIO_NUM 16
#define Y8_GPIO_NUM 17
#define Y7_GPIO_NUM 18
#define Y6_GPIO_NUM 12
#define Y5_GPIO_NUM 10
#define Y4_GPIO_NUM 8
#define Y3_GPIO_NUM 9
#define Y2_GPIO_NUM 11
#define VSYNC_GPIO_NUM 6
#define HREF_GPIO_NUM 7
#define PCLK_GPIO_NUM 13
#define BELL_PIN 21
#define PIR_PIN 14
#define I2S_BCLK 42
#define I2S_LRC 2
#define I2S_DOUT 47
#define SD_MMC_CLK 39
#define SD_MMC_CMD 38
#define SD_MMC_D0 40
AsyncWebServer* server;
Preferences* prefs;
Audio* audio;
bool sdCardReady = false;
String currentSound = "/sounds/munsters.wav";
volatile uint32_t rebootPending = 0;
volatile bool bellPressed = false;
volatile uint32_t lastBellMs = 0;
uint32_t lastTriggerMs = 0;
// =====================================================================================
// --- WEB-INTERFACE ---
// =====================================================================================
const char* htmlUI PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartBell V18.9 (Ultra Fast)</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #eef2f5; color: #333; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; }
h1 { text-align: center; color: #2c3e50; }
.card { background: #fff; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.card h2 { margin-top: 0; border-bottom: 2px solid #3498db; padding-bottom: 10px; font-size: 1.2em; color: #2980b9; }
label { font-weight: bold; display: block; margin-top: 15px; font-size: 0.9em; }
input[type="text"], input[type="password"], input[type="number"], select { width: 100%; padding: 10px; margin-top: 5px; border: 1px solid #ccc; border-radius: 5px; box-sizing: border-box; font-size: 1em; }
input[type="checkbox"] { transform: scale(1.2); margin-right: 10px; }
.help-text { display: block; font-size: 0.8em; color: #7f8c8d; margin-top: 5px; line-height: 1.4; }
button { background: #3498db; color: white; border: none; padding: 12px 20px; font-size: 1em; border-radius: 5px; cursor: pointer; margin-top: 15px; width: 100%; font-weight: bold; }
button:hover { background: #2980b9; }
.btn-test { background: #e67e22; } .btn-test:hover { background: #d35400; }
.btn-sys { background: #27ae60; } .btn-sys:hover { background: #2ecc71; }
.iframe-hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<h1>🔔 SmartBell Admin</h1>
<form action="/save_wifi" method="POST" target="hiddenFrame"><div class="card"><h2>🌐 Netzwerk & WLAN</h2><label>WLAN Name (SSID)</label><input type="text" name="ssid" placeholder="Dein WLAN Name"><label>WLAN Passwort</label><input type="password" name="pw" placeholder="Passwort"><button type="submit">WLAN Speichern & Neustart</button></div></form>
<form action="/save_p" method="POST" target="hiddenFrame"><div class="card"><h2>📱 Push & Benachrichtigungen</h2><label>Android TV IP-Adresse</label><input type="text" name="tvIP" placeholder="z.B. 192.168.178.50"><span class="help-text">TV-Push läuft jetzt im "Text-Only" Turbo-Modus!</span><label>NTFY Server-URL</label><input type="text" name="ntfy_server" placeholder="https://ntfy.sh"><label>NTFY Topic</label><input type="text" name="topic" placeholder="NUR DAS WORT (z.B. smartbell_haustuer)"><label>Alexa VoiceMonkey Trigger URL</label><input type="text" name="vm_url" placeholder="https://api.voicemonkey.io/trigger?..."><button type="submit">Push-Dienste Speichern</button></div></form>
<form action="/save_cam" method="POST" target="hiddenFrame"><div class="card"><h2>📷 Kamera & Hardware</h2><label><input type="checkbox" name="flip" value="1"> Bild horizontal spiegeln (Flip)</label><label>JPEG Qualität (0-63)</label><input type="number" name="qual" value="25" min="0" max="63"><span class="help-text">25 empfohlen für optimale Performance.</span><button type="submit">Hardware Speichern</button></div></form>
<div class="card"><h2>🛠️ System & Tests</h2><button class="btn-test" onclick="fetch('/test_ring')">Test: Klingelknopf simulieren</button><button class="btn-sys" onclick="window.location.href='/update'">OTA Update</button></div>
<iframe name="hiddenFrame" class="iframe-hidden" onload="if(this.contentWindow.location.href.indexOf('save_') > -1) alert('Einstellungen gespeichert!');"></iframe>
</div>
</body>
</html>
)rawliteral";
void applyCamSettings() {
sensor_t *s = esp_camera_sensor_get();
if (!s) return;
s->set_quality(s, prefs->getInt("c_qual", 25));
s->set_hmirror(s, prefs->getInt("c_flip", 0));
s->set_framesize(s, FRAMESIZE_VGA);
}
bool initCamera() {
camera_config_t c = {};
c.ledc_channel = LEDC_CHANNEL_0; c.ledc_timer = LEDC_TIMER_0;
c.pin_d0=11; c.pin_d1=9; c.pin_d2=8; c.pin_d3=10; c.pin_d4=12; c.pin_d5=18; c.pin_d6=17; c.pin_d7=16;
c.pin_xclk=15; c.pin_pclk=13; c.pin_vsync=6; c.pin_href=7; c.pin_sccb_sda=4; c.pin_sccb_scl=5;
c.pin_pwdn=-1; c.pin_reset=-1; c.pixel_format = PIXFORMAT_JPEG;
c.frame_size = FRAMESIZE_VGA; c.jpeg_quality = 25;
c.fb_count = 2;
c.fb_location = CAMERA_FB_IN_PSRAM;
c.xclk_freq_hz = 20000000;
if (esp_camera_init(&c) != ESP_OK) return false;
applyCamSettings(); return true;
}
// =====================================================================================
// --- MICROSERVICES ---
// =====================================================================================
struct ImgJob { uint8_t* jpeg; size_t len; String target; };
struct UrlJob { String target; };
// NTFY Task (Bleibt der einzige Task mit Bild)
void ntfyTaskCode(void* parameter) {
ImgJob* job = (ImgJob*) parameter;
if (WiFi.status() == WL_CONNECTED) {
if (job->target.startsWith("https")) {
WiFiClientSecure sc; sc.setInsecure(); HTTPClient h; h.setTimeout(3000);
if (h.begin(sc, job->target)) { h.addHeader("Title", "Besuch an der Tuer!"); h.POST(job->jpeg, job->len); h.end(); }
} else {
WiFiClient c; c.setTimeout(2); HTTPClient h; h.setTimeout(3000);
if (h.begin(c, job->target)) { h.addHeader("Title", "Besuch an der Tuer!"); h.POST(job->jpeg, job->len); h.end(); }
}
}
free(job->jpeg);
delete job;
vTaskDelete(NULL);
}
void ntfyTextTaskCode(void* parameter) {
UrlJob* job = (UrlJob*) parameter;
if (WiFi.status() == WL_CONNECTED) {
if (job->target.startsWith("https")) {
WiFiClientSecure sc; sc.setInsecure(); HTTPClient h; h.setTimeout(3000);
if (h.begin(sc, job->target)) { h.addHeader("Title", "Klingel (Kein Bild)!"); h.addHeader("Tags", "warning"); h.POST("Jemand hat geklingelt, Kamera liefert kein Bild!"); h.end(); }
} else {
WiFiClient c; c.setTimeout(2); HTTPClient h; h.setTimeout(3000);
if (h.begin(c, job->target)) { h.addHeader("Title", "Klingel (Kein Bild)!"); h.addHeader("Tags", "warning"); h.POST("Jemand hat geklingelt, Kamera liefert kein Bild!"); h.end(); }
}
}
delete job; vTaskDelete(NULL);
}
// NEU: TV Task sendet nur noch federleichtes JSON (Text-Only Modus)
void tvTaskCode(void* parameter) {
UrlJob* job = (UrlJob*) parameter;
if (WiFi.status() == WL_CONNECTED) {
String tvHost = job->target;
if (tvHost.indexOf(':') >= 0 && !tvHost.startsWith("[")) { tvHost = "[" + tvHost + "]"; }
WiFiClient c; c.setTimeout(2); HTTPClient h; h.setTimeout(2500);
if (h.begin(c, "http://" + tvHost + ":7676/")) {
h.addHeader("Content-Type", "application/json");
h.POST("{\"title\":\" Türklingel\",\"msg\":\"Jemand steht an der Haustuer!\"}");
h.end();
}
}
delete job; vTaskDelete(NULL);
}
void alexaTaskCode(void* parameter) {
UrlJob* job = (UrlJob*) parameter;
if (WiFi.status() == WL_CONNECTED) {
WiFiClientSecure scAlexa; scAlexa.setInsecure(); HTTPClient hAlexa; hAlexa.setTimeout(3000);
if (hAlexa.begin(scAlexa, job->target)) { hAlexa.GET(); hAlexa.end(); }
}
delete job; vTaskDelete(NULL);
}
void triggerBellEvent() {
if (millis() - lastTriggerMs < 5000 && lastTriggerMs != 0) { return; }
lastTriggerMs = millis();
audio->connecttoFS(SD_MMC, currentSound.c_str());
String vmUrl = prefs->getString("vm_url", ""); vmUrl.trim();
if (vmUrl.indexOf("voicemonkey") >= 0) {
UrlJob* aJob = new UrlJob(); aJob->target = vmUrl;
xTaskCreatePinnedToCore(alexaTaskCode, "Alexa", 4096, aJob, 1, NULL, 0);
}
// FIX: TV wird jetzt sofort ohne Bild-Kopie angetriggert!
String tvIP = prefs->getString("tvIP", ""); tvIP.trim();
if (tvIP.length() > 0) {
UrlJob* tJob = new UrlJob(); tJob->target = tvIP;
xTaskCreatePinnedToCore(tvTaskCode, "TV", 4096, tJob, 1, NULL, 0);
}
String ntfyTopic = prefs->getString("topic", ""); ntfyTopic.trim();
String srv = prefs->getString("ntfy_server", ""); srv.trim();
if (srv.length() == 0) srv = "https://ntfy.sh";
if (!srv.endsWith("/")) srv += "/";
String fullNtfyUrl = srv + ntfyTopic;
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Kamera Fehler! Sende Text-Notfall...");
if (ntfyTopic.length() > 0) {
UrlJob* nTextJob = new UrlJob(); nTextJob->target = fullNtfyUrl;
xTaskCreatePinnedToCore(ntfyTextTaskCode, "NTFYText", 4096, nTextJob, 1, NULL, 0);
}
return;
}
if (ntfyTopic.length() > 0) {
ImgJob* nJob = new (std::nothrow) ImgJob();
if (nJob && (nJob->jpeg = (uint8_t*)ps_malloc(fb->len))) {
memcpy(nJob->jpeg, fb->buf, fb->len);
nJob->len = fb->len; nJob->target = fullNtfyUrl;
xTaskCreatePinnedToCore(ntfyTaskCode, "NTFY", 6144, nJob, 1, NULL, 0);
} else { delete nJob; }
}
esp_camera_fb_return(fb);
}
void IRAM_ATTR bellISR() { if (millis() - lastBellMs > 500) { bellPressed = true; lastBellMs = millis(); } }
void setup() {
Serial.begin(115200); delay(500);
server = new AsyncWebServer(80); prefs = new Preferences(); audio = new Audio();
prefs->begin("smartbell", false);
SD_MMC.setPins(SD_MMC_CLK, SD_MMC_CMD, SD_MMC_D0); sdCardReady = SD_MMC.begin("/sdcard", true);
initCamera(); audio->setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); audio->setVolume(15);
String savedSSID = prefs->getString("ssid", ""); String savedPW = prefs->getString("pw", "");
if (savedSSID == "") {
WiFi.mode(WIFI_AP); delay(200); WiFi.softAP("SmartBell_Setup", "12345678");
} else {
WiFi.mode(WIFI_STA); delay(200); WiFi.begin(savedSSID.c_str(), savedPW.c_str());
uint32_t startAttempt = millis(); while (WiFi.status() != WL_CONNECTED && millis() - startAttempt < 15000) { delay(500); }
}
esp_wifi_set_ps(WIFI_PS_NONE);
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
if (event == ARDUINO_EVENT_WIFI_STA_CONNECTED) { WiFi.enableIPv6(); }
});
ElegantOTA.begin(server);
server->on("/", WebRequestMethod::HTTP_GET, [](AsyncWebServerRequest *r){ r->send_P(200, "text/html", htmlUI); });
server->on("/save_wifi", WebRequestMethod::HTTP_POST, [](AsyncWebServerRequest *r){ prefs->putString("ssid", r->getParam("ssid", true)->value()); prefs->putString("pw", r->getParam("pw", true)->value()); r->send(200, "text/plain", "OK"); rebootPending = millis() + 1500; });
server->on("/save_p", WebRequestMethod::HTTP_POST, [](AsyncWebServerRequest *r){
if(r->hasParam("tvIP", true)) prefs->putString("tvIP", r->getParam("tvIP", true)->value()); else prefs->putString("tvIP", "");
if(r->hasParam("ntfy_server", true)) prefs->putString("ntfy_server", r->getParam("ntfy_server", true)->value()); else prefs->putString("ntfy_server", "");
if(r->hasParam("topic", true)) prefs->putString("topic", r->getParam("topic", true)->value()); else prefs->putString("topic", "");
if(r->hasParam("vm_url", true)) prefs->putString("vm_url", r->getParam("vm_url", true)->value()); else prefs->putString("vm_url", "");
r->send(200, "text/plain", "OK");
});
server->on("/save_cam", WebRequestMethod::HTTP_POST, [](AsyncWebServerRequest *r){ prefs->putInt("c_qual", r->getParam("qual", true)->value().toInt()); int flip = r->hasParam("flip", true) ? 1 : 0; prefs->putInt("c_flip", flip); applyCamSettings(); r->send(200, "text/plain", "OK"); });
server->on("/test_ring", WebRequestMethod::HTTP_GET, [](AsyncWebServerRequest *r){ bellPressed = true; r->send(200, "text/plain", "Klingel-Event ausgeloest!"); });
pinMode(BELL_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(BELL_PIN), bellISR, FALLING);
server->begin();
}
void loop() {
audio->loop(); ElegantOTA.loop();
if (bellPressed) { bellPressed = false; triggerBellEvent(); }
if (rebootPending > 0 && millis() >= rebootPending) { ESP.restart(); }
delay(2);
}