DIY Fotoapparat mit ESP32-S3, das die Bilder direkt in dein Google Drive oder NextCloud/WebDAV schickt

Heute zeige ich euch ein extrem kompaktes und spannendes Hardware-Projekt: Wir bauen einen eigenen, mobilen Fotoapparat.

Der Clou ist das nach einer Aufnahme über das WLAN die Fotos direkt in euer Google Drive Cloud verschickt werden und somit nichts verloren geht. Eine LED zeigt an das die Kamera aktiv ist und schläft wieder ein, wenn keine Taste gedrückt wird. Das spart Akku Kapazität.

Das Ziel: Ein kleiner Tastendruck genügt, eine gelbe LED (Light-Emitting Diode – Leuchtdiode) leuchtet zur Bestätigung auf, und das aufgenommene Foto landet völlig kabellos über das heimische WLAN (Wireless Local Area Network – kabelloses lokales Netzwerk) direkt im eigenen Google Drive.

Hier sind die Fakten und der komplette Bauplan zum Nachbauen.

Die Hardware: Klein, aber oho

Als Herzstück nutzen wir das Seeed Studio XIAO ESP32S3 Sense. Diese winzige Platine bringt alles mit, was wir brauchen:

  • Einen leistungsstarken ESP32-S3 Mikrocontroller
  • Ein aufsteckbares Kameramodul (OV2640) mit 2 Megapixeln.
  • Eine externe selbstklebende Antenne für guten Empfang.
  • Optional auf der Rückseite zwei Lötstellen zum anbringen eines LiPo-Akku mindestens 1000mA (Lithium-Polymer-Akkumulator), der unser Projekt zusätzlich mobil machen wird, an BAT+ und BAT-.
  • Erwähnenswert aber für diese Projekt nicht berücksichtigt, das Board hat auch noch ein Mikrofon.

Zusätzlich benötigen wir nur:

1x Standard-Taster (als Auslöser)
1x farbige LED incl Vorwiderstand mit Fassung

Der Verdrahtungsplan

Der Aufbau ist sehr übersichtlich, da die Hauptplatine bereits vieles mitbringt.

  1. Kamera und Antenne: Diese werden einfach auf die mitgelieferte Erweiterungsplatine (Sense Expansion Board) gesteckt.
  2. LiPo-Akku (Lithium-Polymer-Akkumulator): Auf der Unterseite der Erweiterungsplatine wird ein 3,7-Volt-Akku angelötet. Das Board kümmert sich automatisch um die Stromversorgung und lädt den Akku sogar auf, wenn du ein USB-Kabel (Universal Serial Bus) anschließt.
  3. Der Taster (Auslöser):
    • Verbinde einen Kontakt des Tasters mit dem Anschlussstift D3 (GPIO 4) auf dem Board.
    • Verbinde den anderen Kontakt des Tasters mit dem Anschlussstift GND (Ground / Masse – der Minuspol).
    • Technischer Hintergrund: Wir nutzen einen internen Widerstand im Mikrocontroller, weshalb kein zusätzliches Bauteil für den Taster nötig ist.
  4. Die gelbe LED (Light-Emitting Diode – Leuchtdiode):
    • Verbinde das lange Bein (Pluspol) der LED mit einem Vorwiderstand (etwa 220 bis 330 Ohm). Das andere Ende des Widerstands verbindest du mit dem Anschlussstift D2 (GPIO 3). Der Widerstand schützt die LED davor, durchzubrennen.
    • Verbinde das kurze Bein (Minuspol) der LED ebenfalls mit GND (Masse).

Quelle und Hersteller Details: https://www.seeedstudio.com/XIAO-ESP32S3-Sense-p-5639.html


Vorbereitung Skript Erstellung bei Google

Das Software-Konzept: Warum Google Drive?

Ein direkter Upload zu Google Fotos ist aufgrund der strengen Sicherheitsanmeldung (OAuth 2.0) für kleine Mikrocontroller viel zu komplex und fehleranfällig.

Die praxisnahe Lösung: Wir nutzen ein kostenloses Google Apps Script. Das ist ein kleines Programm auf den Google-Servern, das als Vermittler dient. Unser ESP32 wandelt das Bild in einen langen Text um (Base64-Verfahren) und sendet diesen über das Internet per HTTP (Hypertext Transfer Protocol) an unser Skript. Das Skript wandelt den Text wieder in ein Bild um und legt es sicher im Google Drive ab.

Schritt 1: Das Google Apps Script einrichten

  • Gehe auf script.google.com und erstelle ein „Neues Projekt“.
  • Füge den folgenden Code in den Editor ein:
Skript zum kopieren:
function doPost(e) {
  try {
    // Nimmt den reinen Base64-Text vom ESP32 entgegen
    var bildText = e.postData.contents;

    // Wandelt den Text zurück in echte Bilddaten
    var decodiertesBild = Utilities.base64Decode(bildText);
    
    // Erzeugt einen einmaligen Dateinamen mit der aktuellen Zeit
    var dateiName = "Kamera_" + new Date().getTime() + ".jpg";

    // Erstellt die Datei für Google Drive
    var dateiFormat = Utilities.newBlob(decodiertesBild, 'image/jpeg', dateiName);
    var ordner = DriveApp.getRootFolder();
    var neueDatei = ordner.createFile(dateiFormat);

    return ContentService.createTextOutput("Erfolg! Bild gespeichert als: " + dateiName);
    
  } catch (fehler) {
    return ContentService.createTextOutput("Server-Fehler: " + fehler.toString());
  }
}
  • Klicke oben rechts auf Bereitstellen -> Neue Bereitstellung.
  • Wähle als Typ das Zahnrad und dann Web-App.
  • Stelle „Wer hat Zugriff“ erstmal auf Jeder. (Nur temporär öffnen und auf eigene Gefahr) Besser Zugriff beschränken auf „Jeder mit Google-Konto“ oder „Nur ich“ oder WebDAV Lösung siehe Teil 2
  • Kopiere dir am Ende die angezeigte Web-App URL (Uniform Resource Locator – die genaue Internetadresse, die mit /exec endet). Diese brauchen wir gleich für den Code.


Den Mikrocontroller programmieren

Wichtiger Fakt vorab: Damit die Kamera genug Speicherplatz hat, um das Bild aufzubauen, musst du vor dem Hochladen in deiner Programmierumgebung zwingend den PSRAM (Pseudo-Static Random Access Memory – ein zusätzlicher Arbeitsspeicher auf der Platine) aktivieren!

  • In der Arduino-IDE: Board als XIAO_ESP32S3 und unter Werkzeuge -> PSRAM -> „OPI PSRAM„.
  • In PlatformIO (VS Code): In der platformio.ini unter build_flags = -D BOARD_HAS_PSRAM eintragen.

Hier ist der fertige Sketch. Trage oben deine WLAN-Daten und deine frisch kopierte Google Web-App URL ein

Quellcode:
#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <base64.h> // NEU: Für die Text-Übersetzung des Bildes

// --- Hier deine Daten eintragen ---
const char* netzwerkName = "ID-Labor";
const char* netzwerkPasswort = "geheimesPasswort";
String zielAdresse = "https://script.google.com/macros/skript_Web-App_url_einfügen/exec";

// --- Anschlussstifte (Pins) ---
const int tasterPin = 4; // GPIO 4 ist D3
const int ledPin = 3;    // GPIO 3 ist D2

// --- Kamera-Anschlüsse ---
#define PWDN_GPIO_NUM     -1
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM     10
#define SIOD_GPIO_NUM     40
#define SIOC_GPIO_NUM     39
#define Y9_GPIO_NUM       48
#define Y8_GPIO_NUM       11
#define Y7_GPIO_NUM       12
#define Y6_GPIO_NUM       14
#define Y5_GPIO_NUM       16
#define Y4_GPIO_NUM       18
#define Y3_GPIO_NUM       17
#define Y2_GPIO_NUM       15
#define VSYNC_GPIO_NUM    38
#define HREF_GPIO_NUM     47
#define PCLK_GPIO_NUM     13

void setup() {
  Serial.begin(115200);

  pinMode(tasterPin, INPUT_PULLUP); 
  pinMode(ledPin, OUTPUT);
  digitalWrite(ledPin, LOW); 

  Serial.print("Verbinde mit WLAN (Wireless Local Area Network – kabelloses lokales Netzwerk)...");
  WiFi.begin(netzwerkName, netzwerkPasswort);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" Erfolgreich verbunden!");

  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.frame_size = FRAMESIZE_SVGA; 
  config.pixel_format = PIXFORMAT_JPEG; // JPEG (Joint Photographic Experts Group – digitales Bildformat)
  config.jpeg_quality = 10;
  config.fb_count = 1;

  esp_err_t fehler = esp_camera_init(&config);
  if (fehler != ESP_OK) {
    Serial.printf("Kamera-Start fehlgeschlagen: 0x%x", fehler);
    return;
  }

  // Bild um 180 Grad drehen, wenn wie bei mir verdreht im Gehaeuse eingebaut
  sensor_t * s = esp_camera_sensor_get();
  if (s != NULL) {
    s->set_vflip(s, 1);    // Schritt 1: Bild vertikal spiegeln (oben und unten tauschen)
    s->set_hmirror(s, 1);  // Schritt 2: Bild horizontal spiegeln (links und rechts tauschen)
    Serial.println("Kamerabild wurde per Software um 180 Grad gedreht.");
  }

  Serial.println("Kamera ist bereit. Warte auf Knopfdruck.");
}

void loop() {
   
  if (digitalRead(tasterPin) == LOW) {
    digitalWrite(ledPin, HIGH);
    Serial.println("Klick! Auslöser gedrückt...");

    // Den alten Zwischenspeicher leeren (Scheinschuss / Dummy-Shot)
    camera_fb_t * alterPuffer = esp_camera_fb_get();
    if (alterPuffer) {
      esp_camera_fb_return(alterPuffer); // Das alte Bild wird sofort ungelesen gelöscht
      Serial.println("Alten Bildpuffer geleert.");
    }

    // Jetzt das brandaktuelle Foto aufnehmen
    Serial.println("Neues Foto aufnehmen...");
    camera_fb_t * bildspeicher = esp_camera_fb_get();
    if (!bildspeicher) {
      Serial.println("Fehler bei der Aufnahme.");
      digitalWrite(ledPin, LOW);
      return;
    }

    // Das aktuelle Foto an Google Drive senden
    if (WiFi.status() == WL_CONNECTED) {
      HTTPClient http; 
      http.begin(zielAdresse);
      
      http.addHeader("Content-Type", "text/plain"); 
      
      Serial.println("Übersetze Bild in Text (Base64)...");
      String base64Bild = base64::encode(bildspeicher->buf, bildspeicher->len);
      
      Serial.println("Sende Bild an Google...");
      int antwortCode = http.POST(base64Bild);

      if (antwortCode == 200 || antwortCode == 302) {
        Serial.printf("Erfolg! Bild ist auf dem Weg (Antwortcode: %d)\n", antwortCode);
      } else {
        Serial.printf("Fehler beim Senden: Code %d\n", antwortCode);
      }
      
      http.end();
    } else {
      Serial.println("Keine WLAN-Verbindung.");
    }

    // Speicher freigeben und LED ausschalten
    esp_camera_fb_return(bildspeicher);
    digitalWrite(ledPin, LOW);
    
    // Pause zur Entprellung des Tasters
    delay(2000); 
  }
}

Erster Test: Ergebnis im Seriellen Monitor

Drücke den Knopf und wenige Sekunden später findest du dein Foto in deinem Google Drive:

Zwei wichtige Stolpersteine aus der Praxis

1. Warum das Bild plötzlich auf dem Kopf stand
Beim Design des dreidimensional (3D) gedruckten Gehäuses gab es eine logische Vorgabe: Der USB-C-Anschluss zum Laden des Akkus muss unbedingt an der Unterseite sitzen. Da das mitgelieferte Flachbandkabel der Kamera extrem kurz ist, bedeutet das im Umkehrschluss, dass das gesamte Innenleben umgedreht eingebaut werden muss. Die Folge: Die Bilder landeten auf dem Kopf. Die Lösung: Ich musste das Gehäuse nicht neu designen. Der OV2640-Sensor erlaubt es, das Bild per Software im setup() horizontal und vertikal zu spiegeln. Das kostet beim Auslösen keine Rechenzeit!

2. Das Rätsel um das „vorletzte“ Bild
Bei den ersten Tests landete auf Google Drive seltsamerweise nie das aktuelle Foto, sondern immer das Bild vom vorherigen Knopfdruck. Grund ist der interne Bildzwischenspeicher (Frame-Buffer) der Kamera. Der Sensor läuft permanent im Hintergrund. In der zweisekündigen Pause nach dem Senden schießt die Kamera munter weiter und füllt den Speicher mit einem alten Bild. Beim nächsten Druck holt man genau diese „Konserve“ ab.
Die Lösung: Ein digitaler „Scheinschuss“ (Dummy-Shot). Sobald der Taster gedrückt wird, holen wir das erste Bild ab und werfen es sofort ungelesen weg. Erst das direkt danach geschossene zweite Foto wird an Google gesendet. So ist das Bild immer brandaktuell.


3D Druck:

Download der fertigen Druckdateien https://makerworld.com/de/models/2892682-diy-esp32s3-xiao-sense-variable-camera-case

Hier der Openscad Skript zum manuellen Anpassen.
Die Ablaufsteuerung ist Standardmäßig für die Vorderseite gezeichnet. Um die Rückwand zu sehen,
entferne die Schrägstriche vor „rueckwand();“ und setze welche vor „vorderseite();
Danach rendern und als STL-Datei exportieren

openSCAD Code
// =========================================================================
// OPENSCAD-SKRIPT: KAMERAGEHÄUSE FÜR SEEED STUDIO XIAO ESP32S3 SENSE
// =========================================================================

// --- 1. Globale Gehäusemaße (in mm) ---
gehaeuse_breite = 80;    // Gesamtbreite von links nach rechts (Limit: 120 mm)
gehaeuse_hoehe = 50;     // Gesamthöhe von unten nach oben
gehaeuse_tiefe = 25;     // Tiefe des Hauptkörpers (ohne Objektiv)
wandstaerke = 2;         // Dicke der Außenwände
ecken_radius = 4;        // Radius für die Abrundung der Gehäuseecken

// --- 2. Parameter für den Schnappverschluss (Möglichkeit 1) ---
verschluss_nut_tiefe_z = 1.5;   // Position der Rille im Gehäuse (ab Rückseite)
verschluss_wulst_aufmass = 0.1; // Wie weit die Rastnase übersteht (Toleranz)
// Verschiebung der Wulst auf Z (Möglichkeit 1 für tieferes Eintauchen):
verschluss_wulst_z_offset = 2.2; 

// --- 3. Parameter für Bedienelemente & Durchbrüche ---
taster_durchmesser = 7;
taster_x_pos = 15;

led_durchmesser = 7;
led_x_pos = 65;

usb_breite = 12;
usb_hoehe = 7;
usb_x_pos = 33;

// --- 4. Parameter für die ESP-Halterung ---
halterung_esp_boden_breite = 18;
halterung_esp_boden_hoehe = 13;
halterung_esp_boden_tiefe = 3.5;
// Halterung Positionierung im Innenraum:
halterung_esp_seitenwand_hoehe = 12;
esp_pos_x = 40;
esp_pos_y = 42;
esp_pos_z = 22;

// Kreisauflösung für alle runden Formen
$fn = 64;

// --- Ablaufsteuerung ---
vorderseite();
//rueckwand();


// =========================================================================
// HAUPTMODULE
// =========================================================================

module vorderseite() {
    difference() {
        
        // 1. POSITIV-FORM: Alle sichtbaren Außenteile verschmelzen
        union() {
            grundkoerper();
            objektiv_attrappe();
            bedienelemente_oben();
            esphalterung();
        }

        // 2. NEGATIV-FORM: Hier wird der Innenraum komplett ausgehöhlt
        translate([wandstaerke, wandstaerke, -1])
            abgerundeter_kasten(
                gehaeuse_breite - (2 * wandstaerke), 
                gehaeuse_hoehe - (2 * wandstaerke),  
                gehaeuse_tiefe - wandstaerke + 1     
            );

        // 3. NUT FÜR DEN SCHNAPPVERSCHLUSS
        translate([wandstaerke - 0.5, wandstaerke - 0.5, verschluss_nut_tiefe_z])
            abgerundeter_kasten(
                gehaeuse_breite - (2 * wandstaerke) + 1, 
                gehaeuse_hoehe - (2 * wandstaerke) + 1,  
                1 // Höhe der Nut
            );

        // 4. DURCHBRUCH FÜR DIE LINSE
        translate([gehaeuse_breite / 2, gehaeuse_hoehe / 2, gehaeuse_tiefe - wandstaerke - 1])
            cylinder(h = 20, d = 8); 

        // 5. AUSSPARUNG FÜR DEN USB-C-ANSCHLUSS
        translate([usb_x_pos, gehaeuse_hoehe - 51, (gehaeuse_tiefe / 2) - 2.5])
             cube([wandstaerke + usb_breite, usb_breite, usb_hoehe]); 

        // 6. DURCHGEHENDES LOCH FÜR DEN INTERNEN TASTER
        translate([taster_x_pos, gehaeuse_hoehe - 5, gehaeuse_tiefe / 2])
            rotate([-90, 0, 0]) 
            cylinder(h = 15, d = taster_durchmesser); 
            
        // 7. DURCHGEHENDES LOCH FÜR LED
        translate([led_x_pos, gehaeuse_hoehe - 5, gehaeuse_tiefe / 2])
            rotate([-90, 0, 0]) 
            cylinder(h = 15, d = led_durchmesser); 
    }
    esphalterung();
}

module rueckwand() {
    translate([0, -gehaeuse_hoehe - 10, 0]) {
        union() {
            // 1. Die flache Außenplatte als Gehäusedeckel
            difference() {
                abgerundeter_kasten(gehaeuse_breite, gehaeuse_hoehe, wandstaerke);
            }
            
            // 2. Die innenliegende Basis-Lippe (Führungsschiene)
            translate([wandstaerke + 0.2, wandstaerke + 0.2, wandstaerke])
                difference() {
                    abgerundeter_kasten(
                        gehaeuse_breite - (2 * wandstaerke) - 0.4, 
                        gehaeuse_hoehe - (2 * wandstaerke) - 0.4, 
                        2 
                    );
                    translate([2, 2, -1])
                        abgerundeter_kasten(
                            gehaeuse_breite - (2 * wandstaerke) - 4.4, 
                            gehaeuse_hoehe - (2 * wandstaerke) - 4.4, 
                            4
                        );
                }
            
            // 3. DIE RASTNASE (Wulst angepasst nach Möglichkeit 1)
            translate([wandstaerke + 0.05, wandstaerke + 0.05, wandstaerke + verschluss_wulst_z_offset])
                difference() {
                    abgerundeter_kasten(
                        gehaeuse_breite - (2 * wandstaerke) - (0.2 - verschluss_wulst_aufmass * 2), 
                        gehaeuse_hoehe - (2 * wandstaerke) - (0.2 - verschluss_wulst_aufmass * 2), 
                        1 
                    );
                    translate([2, 2, -1])
                        abgerundeter_kasten(
                            gehaeuse_breite - (2 * wandstaerke) - 4.1, 
                            gehaeuse_hoehe - (2 * wandstaerke) - 4.1, 
                            3
                        );
                }
        }
    }
}


// =========================================================================
// HILFS- UND DETAILMODULE
// =========================================================================

module grundkoerper() {
    abgerundeter_kasten(gehaeuse_breite, gehaeuse_hoehe, gehaeuse_tiefe);
}

module esphalterung() {
    // Erzeugt den Boden vom ESP unter Verwendung der Variablen
    translate([esp_pos_x, esp_pos_y, esp_pos_z]) 
        cube([halterung_esp_boden_breite, halterung_esp_boden_hoehe, halterung_esp_boden_tiefe + 1], center = true); 
    
    // Linke Seitenwand
    translate([esp_pos_x - 10, esp_pos_y - 5, esp_pos_z - 4]) 
        cube([2, 18, halterung_esp_seitenwand_hoehe], center = true); 
    
    // Rechte Seitenwand
    translate([esp_pos_x + 10, esp_pos_y - 5, esp_pos_z - 4]) 
        cube([2, 18, halterung_esp_seitenwand_hoehe], center = true); 
}

module abgerundeter_kasten(b, h, t) {
    hull() {
        translate([ecken_radius, ecken_radius, 0]) cylinder(h=t, r=ecken_radius);
        translate([b - ecken_radius, ecken_radius, 0]) cylinder(h=t, r=ecken_radius);
        translate([ecken_radius, h - ecken_radius, 0]) cylinder(h=t, r=ecken_radius);
        translate([b - ecken_radius, h - ecken_radius, 0]) cylinder(h=t, r=ecken_radius);
    }
}

module objektiv_attrappe() {
    translate([gehaeuse_breite / 2, gehaeuse_hoehe / 2, gehaeuse_tiefe]) {
        difference() {
            cylinder(h = 4, d = 36); 
            translate([0, 0, -1])
                cylinder(h = 6, d = 28); 
        }
    }
}

module bedienelemente_oben() {
    // LINKER AUSLÖSER BUTTON
    translate([15, gehaeuse_hoehe, gehaeuse_tiefe / 2])
    rotate([-90, 0, 0]) { 
        cylinder(h = 2, d = 14); 
        translate([0, 0, 2]) 
            cylinder(h = 3, d = 10); 
    }

    // RECHTES DREHRAD
    translate([gehaeuse_breite - 15, gehaeuse_hoehe, gehaeuse_tiefe / 2])
    rotate([-90, 0, 0]) {
        cylinder(h = 3, d = 12); 
    }

    // DETAILLIERTER BLITZSCHUH NACH ISO-STANDARD
    translate([gehaeuse_breite / 2, gehaeuse_hoehe, gehaeuse_tiefe / 2]) {
        difference() {
            translate([0, 2, 0]) 
                cube([22, 4, 14], center = true); 
            translate([0, 2.8, 0]) 
                cube([19, 2.5, 16], center = true); 
        }
        translate([0, 1, 0]) 
            rotate([-90, 0, 0])
            cylinder(h = 1, d = 3);
    }
}

Viel Spaß beim Tüfteln und Nachbauen!

Youtube Video:

Kamera-Upgrades: Welche Sensoren du alternativ einsetzen kannst

Da wir in diesem „Do-It-Yourself“ (DIY) Projekt den leistungsstarken Mikrocontroller der Serie „Espressif System 32“ in der Variante S3 (ESP32-S3) nutzen, sind wir nicht zwingend auf den klassischen Standard-Sensor OV2640 („OmniVision 2640“) festgelegt. Wegen des zusätzlichen PSRAM im S3-Chip, gibt es uns die Freiheit, Sensoren mit höheren Auflösungen oder speziellen Eigenschaften anzuschließen, um die Bildqualität in Google Drive noch weiter zu verbessern.
Auf den richtigen Anschluss achten: Die meisten ESP32-S3 Kamera-Entwicklungsboards (Development Boards) nutzen ein kleines flaches Bandkabel („Flexible Printed Circuit“ – FPC) mit 24 Pins. Das neue Kameramodul muss mechanisch genau in diese kleine Buchse passen.

Hier ist eine Übersicht der gängigsten Alternativen, die mechanisch an den gleichen 24-Pin-Anschluss deines Entwicklungsboards (Development Board) passen:

KameramodulMegapixel (MP)Maximale AuflösungBesonderheit für dieses Projekt
OV26402 Megapixel1600 x 1200 PixelDer günstige und schnelle Standard.
OV36603 Megapixel2048 x 1536 PixelEtwas lichtstärker, oft mit einem breiteren und natürlicheren Sichtfeld.
OV56405 Megapixel2592 x 1944 PixelDeutlich schärfere Fotos. Ideal, wenn du in die bei Google Drive gespeicherten Bilder hineinzoomen möchtest.
NoIR-Versionen2 oder 5 MegapixelJe nach Basissensor„No Infrared“ (NoIR) bedeutet: Es fehlt der Infrarot-Filter auf der Linse. Das ist perfekt für Nachtaufnahmen in Kombination mit unsichtbaren Infrarot-Leuchtdioden (LEDs). Achtung: Am Tag wirken die Bilder dadurch stark pink oder violett.

Was muss im Programmcode angepasst werden?

Die gute Nachricht: Die Standard-Programmbibliothek erkennt den angeschlossenen Sensor (egal ob OV2640, OV3660 oder OV5640) beim Start automatisch. Auch wenn du eine NoIR-Kamera verwendest, musst du dafür keinen speziellen Code schreiben, da die Technik im Inneren identisch bleibt und nur das winzige Filterglas fehlt.

Damit die Bilder aber in der vollen Auflösung deines neuen Sensors an die Schnittstelle gesendet werden, musst du im Quellcode lediglich die Bildgröße (frame_size) anpassen. Suche in deinem Code nach der Kamera-Konfiguration (meist direkt in der setup()-Funktion zu finden) und ändere die Einstellungen wie folgt:

// Kamera-Konfiguration
camera_config_t config;

// ... [Deine bestehende Pin-Belegung bleibt genau so stehen] ...

// ==========================================
// 1. BILDGRÖSSE (AUFLÖSUNG) EINSTELLEN
// ==========================================
// Entferne die zwei Schrägstriche (//) vor dem Sensor, den du nutzt:

// Option A: Standard für den OV2640 (und OV2640 NoIR)
config.frame_size = FRAMESIZE_UXGA;    // 1600 x 1200 Pixel (2 Megapixel)

// Option B: Upgrade für den OV3660
// config.frame_size = FRAMESIZE_QXGA;   // 2048 x 1536 Pixel (3 Megapixel)

// Option C: Upgrade für den OV5640 (und OV5640 NoIR)
// config.frame_size = FRAMESIZE_QSXGA;  // 2592 x 1944 Pixel (5 Megapixel)


// ==========================================
// 2. BILDQUALITÄT EINSTELLEN
// ==========================================
// "jpeg_quality" bestimmt die Dateigröße der Bilder. 
// Ein niedrigerer Wert bedeutet bessere Qualität, aber längere Upload-Zeiten in die Cloud.
// Wertebereich: 10 (sehr gut) bis 63 (stark komprimiert)

config.jpeg_quality = 12; 

Wichtiger Hinweis zum Speicher:
Nicht vergessen, den wie oben beschrieben PSRAM zu aktivieren.

Weitere Verbesserungsvorschläge:
Nun könnt ihr um länger mit dem akku zu arbeiten, noch esp_sleep_enable_ext0_wakeup(GPIO_NUM_4, 0); (Taster an GPIO 4) ergänzen


FreeCAD Makro:

Auch mit FreeCAD kann per Makro (Klick hier) diese Kamera erstellt oder angepasst werden
import FreeCAD as App
import Part

# =========================================================================
# FREECAD-MAKRO: KAMERAGEHÄUSE FÜR SEEED STUDIO XIAO ESP32S3 SENSE
# =========================================================================

# --- 1. Globale Gehäusemaße (in mm) ---
gehaeuse_breite = 80
gehaeuse_hoehe = 50
gehaeuse_tiefe = 25
wandstaerke = 2
ecken_radius = 4

# --- 2. Parameter für den Schnappverschluss ---
verschluss_nut_tiefe_z = 1.5
verschluss_wulst_aufmass = 0.1
verschluss_wulst_z_offset = 2.2

# --- 3. Parameter für Bedienelemente & Durchbrüche ---
taster_durchmesser = 7
taster_x_pos = 15

led_durchmesser = 7
led_x_pos = 65

usb_breite = 12
usb_hoehe = 7
usb_x_pos = 33

# --- 4. Parameter für die ESP-Halterung ---
halterung_esp_boden_breite = 18
halterung_esp_boden_hoehe = 13
halterung_esp_boden_tiefe = 3.5
halterung_esp_seitenwand_hoehe = 12
esp_pos_x = 40
esp_pos_y = 42
esp_pos_z = 22

# =========================================================================
# HILFS- UND DETAILMODULE
# =========================================================================

def abgerundeter_kasten(b, h, t, r=ecken_radius):
    """Baut einen Kasten mit abgerundeten Ecken, ähnlich wie hull() in OpenSCAD"""
    if r <= 0:
        return Part.makeBox(b, h, t)
    
    # Vier Zylinder in den Ecken
    c1 = Part.makeCylinder(r, t, App.Vector(r, r, 0))
    c2 = Part.makeCylinder(r, t, App.Vector(b - r, r, 0))
    c3 = Part.makeCylinder(r, t, App.Vector(r, h - r, 0))
    c4 = Part.makeCylinder(r, t, App.Vector(b - r, h - r, 0))
    
    # Zwei sich kreuzende Rechtecke, um die Lücken zu füllen
    box1 = Part.makeBox(b - 2*r, h, t, App.Vector(r, 0, 0))
    box2 = Part.makeBox(b, h - 2*r, t, App.Vector(0, r, 0))
    
    res = c1.fuse(c2).fuse(c3).fuse(c4).fuse(box1).fuse(box2)
    return res.removeSplitter()

def grundkoerper():
    return abgerundeter_kasten(gehaeuse_breite, gehaeuse_hoehe, gehaeuse_tiefe)

def objektiv_attrappe():
    c1 = Part.makeCylinder(36/2, 4)
    c2 = Part.makeCylinder(28/2, 6)
    c2.translate(App.Vector(0, 0, -1))
    res = c1.cut(c2)
    res.translate(App.Vector(gehaeuse_breite / 2, gehaeuse_hoehe / 2, gehaeuse_tiefe))
    return res

def bedienelemente_oben():
    # Linker Auslöser Button
    aus1 = Part.makeCylinder(14/2, 2)
    aus2 = Part.makeCylinder(10/2, 3)
    aus2.translate(App.Vector(0, 0, 2))
    ausloeser = aus1.fuse(aus2)
    ausloeser.rotate(App.Vector(0,0,0), App.Vector(1,0,0), -90)
    ausloeser.translate(App.Vector(15, gehaeuse_hoehe, gehaeuse_tiefe / 2))

    # Rechtes Drehrad
    drehrad = Part.makeCylinder(12/2, 3)
    drehrad.rotate(App.Vector(0,0,0), App.Vector(1,0,0), -90)
    drehrad.translate(App.Vector(gehaeuse_breite - 15, gehaeuse_hoehe, gehaeuse_tiefe / 2))

    # Blitzschuh
    b1 = Part.makeBox(22, 4, 14)
    b1.translate(App.Vector(-11, -2, -7))
    b1.translate(App.Vector(0, 2, 0))
    
    b2 = Part.makeBox(19, 2.5, 16)
    b2.translate(App.Vector(-9.5, -1.25, -8))
    b2.translate(App.Vector(0, 2.8, 0))
    
    blitz = b1.cut(b2)
    
    b3 = Part.makeCylinder(3/2, 1)
    b3.rotate(App.Vector(0,0,0), App.Vector(1,0,0), -90)
    b3.translate(App.Vector(0, 1, 0))
    blitz = blitz.fuse(b3)
    blitz.translate(App.Vector(gehaeuse_breite / 2, gehaeuse_hoehe, gehaeuse_tiefe / 2))
    
    return ausloeser.fuse(drehrad).fuse(blitz)

def esphalterung():
    # Boden
    boden = Part.makeBox(halterung_esp_boden_breite, halterung_esp_boden_hoehe, halterung_esp_boden_tiefe + 1)
    boden.translate(App.Vector(-halterung_esp_boden_breite/2, -halterung_esp_boden_hoehe/2, -(halterung_esp_boden_tiefe + 1)/2))
    boden.translate(App.Vector(esp_pos_x, esp_pos_y, esp_pos_z))
    
    # Linke Seitenwand
    links = Part.makeBox(2, 18, halterung_esp_seitenwand_hoehe)
    links.translate(App.Vector(-1, -9, -halterung_esp_seitenwand_hoehe/2))
    links.translate(App.Vector(esp_pos_x - 10, esp_pos_y - 5, esp_pos_z - 4))
    
    # Rechte Seitenwand
    rechts = Part.makeBox(2, 18, halterung_esp_seitenwand_hoehe)
    rechts.translate(App.Vector(-1, -9, -halterung_esp_seitenwand_hoehe/2))
    rechts.translate(App.Vector(esp_pos_x + 10, esp_pos_y - 5, esp_pos_z - 4))
    
    return boden.fuse(links).fuse(rechts)

# =========================================================================
# HAUPTMODULE
# =========================================================================

def vorderseite():
    # 1. Positiv-Form
    gk = grundkoerper()
    oa = objektiv_attrappe()
    bo = bedienelemente_oben()
    eh1 = esphalterung()
    
    positiv = gk.fuse(oa).fuse(bo).fuse(eh1)
    
    # 2. Negativ-Form (Innenraum)
    neg = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke, 
                              gehaeuse_hoehe - 2*wandstaerke, 
                              gehaeuse_tiefe - wandstaerke + 1)
    neg.translate(App.Vector(wandstaerke, wandstaerke, -1))
    
    # 3. Nut für Schnappverschluss
    nut = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke + 1, 
                              gehaeuse_hoehe - 2*wandstaerke + 1, 1)
    nut.translate(App.Vector(wandstaerke - 0.5, wandstaerke - 0.5, verschluss_nut_tiefe_z))
    
    # 4. Durchbruch für die Linse
    linse = Part.makeCylinder(8/2, 20)
    linse.translate(App.Vector(gehaeuse_breite / 2, gehaeuse_hoehe / 2, gehaeuse_tiefe - wandstaerke - 1))
    
    # 5. Aussparung USB-C
    usb = Part.makeBox(wandstaerke + usb_breite, usb_breite, usb_hoehe)
    usb.translate(App.Vector(usb_x_pos, gehaeuse_hoehe - 51, (gehaeuse_tiefe / 2) - 2.5))
    
    # 6. Loch für Taster
    taster = Part.makeCylinder(taster_durchmesser/2, 15)
    taster.rotate(App.Vector(0,0,0), App.Vector(1,0,0), -90)
    taster.translate(App.Vector(taster_x_pos, gehaeuse_hoehe - 5, gehaeuse_tiefe / 2))
    
    # 7. Loch für LED
    led = Part.makeCylinder(led_durchmesser/2, 15)
    led.rotate(App.Vector(0,0,0), App.Vector(1,0,0), -90)
    led.translate(App.Vector(led_x_pos, gehaeuse_hoehe - 5, gehaeuse_tiefe / 2))
    
    # Subtraktion aller Negativformen
    result = positiv.cut(neg).cut(nut).cut(linse).cut(usb).cut(taster).cut(led)
    
    # ESP-Halterung nochmal hinzufügen (wie im Original)
    eh2 = esphalterung()
    result = result.fuse(eh2)
    
    return result.removeSplitter()

def rueckwand():
    # 1. Flache Außenplatte
    platte = abgerundeter_kasten(gehaeuse_breite, gehaeuse_hoehe, wandstaerke)
    
    # 2. Basis-Lippe
    lippe_aussen = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke - 0.4, 
                                       gehaeuse_hoehe - 2*wandstaerke - 0.4, 2)
    lippe_innen = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke - 4.4, 
                                      gehaeuse_hoehe - 2*wandstaerke - 4.4, 4)
    lippe_innen.translate(App.Vector(2, 2, -1))
    lippe = lippe_aussen.cut(lippe_innen)
    lippe.translate(App.Vector(wandstaerke + 0.2, wandstaerke + 0.2, wandstaerke))
    
    # 3. Rastnase
    rast_aussen = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke - (0.2 - verschluss_wulst_aufmass*2),
                                      gehaeuse_hoehe - 2*wandstaerke - (0.2 - verschluss_wulst_aufmass*2), 1)
    rast_innen = abgerundeter_kasten(gehaeuse_breite - 2*wandstaerke - 4.1,
                                     gehaeuse_hoehe - 2*wandstaerke - 4.1, 3)
    rast_innen.translate(App.Vector(2, 2, -1))
    rast = rast_aussen.cut(rast_innen)
    rast.translate(App.Vector(wandstaerke + 0.05, wandstaerke + 0.05, wandstaerke + verschluss_wulst_z_offset))
    
    rw = platte.fuse(lippe).fuse(rast)
    rw.translate(App.Vector(0, -gehaeuse_hoehe - 10, 0))
    return rw.removeSplitter()

# =========================================================================
# AUSFÜHRUNG UND ERZEUGUNG IN FREECAD
# =========================================================================

# Ein neues Dokument erstellen, falls noch keines offen ist
if App.ActiveDocument is None:
    App.newDocument("ESP32_Kamera")

# Vorderseite erstellen und in den Baum einfügen
obj_vorderseite = App.ActiveDocument.addObject("Part::Feature", "Vorderseite")
obj_vorderseite.Shape = vorderseite()

# Rückwand erstellen (auskommentiert wie in deinem Skript)
# obj_rueckwand = App.ActiveDocument.addObject("Part::Feature", "Rueckwand")
# obj_rueckwand.Shape = rueckwand()

# Ansicht aktualisieren
App.ActiveDocument.recompute()


Kopiere diesen Code, öffne FreeCAD, wechsle in das Menü Makro -> Makros…, klicke auf Erstellen,
vergib einen Namen (z. B. Kameragehaeuse.FCMacro) und füge den Text dort ein.
Danach kannst du es einfach mit dem grünen „Play“-Button ausführen.


Teil 2: Bilder sicher in die eigene Nextcloud (WebDAV) senden

In Teil 1 unseres DIY-Fotoapparats (Do It Yourself) haben wir den ESP32-S3 so programmiert, dass er seine Schnappschüsse direkt in Google Drive ablegt. Das funktioniert super, aber viele von uns haben ihre Daten lieber auf eigenen Servern oder bei europäischen Cloud-Anbietern.

Deshalb zeige ich euch in diesem nachträglichen Update, wie wir den Code so umbauen, dass der ESP32-S3 seine Bilder über eine verschlüsselte Verbindung in eine Nextcloud (oder jede andere Cloud mit WebDAV-Unterstützung) hochlädt.

Was ändert sich im Vergleich zu Teil 1?

Wenn wir von der Google-API (Application Programming Interface – eine Programmierschnittstelle) zu einer Standard-WebDAV-Lösung (Web-based Distributed Authoring and Versioning – ein Standard zur Bereitstellung von Dateien im Netz) wechseln, müssen wir drei Dinge im Code anpassen:

FunktionGoogle Drive (Teil 1)Nextcloud / WebDAV (Teil 2)
ÜbertragungswegHTTP POST (sendet Daten an ein Skript)HTTP PUT (legt eine Datei direkt ab)
SicherheitDer einfache HTTPClient reichte ausWir brauchen WiFiClientSecure für echtes HTTPS (Hypertext Transfer Protocol Secure)
AuthentifizierungDer geheime Schlüssel steckte in der Google-URLWir nutzen Benutzername und ein App-Passwort
DateinameWurde automatisch vom Google-Skript erzeugtMuss vom ESP32-S3 selbst generiert werden

Vorbereitung: Das App-Passwort

Wichtig: Tragt niemals euer normales Nextcloud-Passwort in den Code des Mikrocontrollers ein! Wenn ihr 2FA (Zwei-Faktor-Authentifizierung) nutzt, funktioniert das ohnehin nicht.

Geht in eurer Nextcloud oben rechts auf Einstellungen -> Sicherheit und erstellt ganz unten ein neues App-Passwort (zum Beispiel mit dem Namen „ESP32-Kamera“). Dieses Passwort kopiert ihr euch für den Code.

Der neue Code für den Upload

Da wir beim WebDAV-Upload den Dateinamen selbst festlegen müssen, nutzen wir das NTP (Network Time Protocol), um uns die aktuelle Uhrzeit aus dem Internet zu holen. So bekommt jedes Bild einen einzigartigen Namen (z. B. 20260707_161230.jpg) und nichts wird überschrieben.

Ersetze die Upload-Funktion aus Teil 1 durch diesen neuen Block. Achte darauf, dass der Ordner fotos in deiner Nextcloud bereits existieren muss!

#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h> // Für den Zeitstempel im Dateinamen

// --- Deine Zugangsdaten ---
const char* webdavUser = "DEIN_BENUTZERNAME";
const char* webdavPass = "DEIN_APP_PASSWORT"; // Das neu erstellte App-Passwort!
const char* ntpServer = "pool.ntp.org";

// --- Setup-Ergänzung (nur einmal im setup() nach dem WLAN-Verbinden aufrufen) ---
void setupZeit() {
  // Zeitzone konfigurieren: +3600 Sekunden (1 Stunde) für Deutschland, 
  // plus weitere 3600 Sekunden für die Sommerzeit.
  configTime(3600, 3600, ntpServer);
  Serial.println("Warte auf Internet-Zeit...");
}

// --- Hilfsfunktion für den Dateinamen ---
String generiereDateiname() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo)) {
    Serial.println("Fehler beim Abrufen der Zeit. Nutze Notfall-Namen.");
    return "bild_" + String(millis()) + ".jpg"; 
  }
  char zeitText[30];
  // Format: JahrMonatTag_StundeMinuteSekunde.jpg
  strftime(zeitText, sizeof(zeitText), "%Y%m%d_%H%M%S.jpg", &timeinfo);
  return String(zeitText);
}

// --- Die eigentliche Upload-Funktion ---
void ladeBildHoch(uint8_t* bildDaten, size_t bildGroesse) {
  // 1. Sichere Verbindung aufbauen
  WiFiClientSecure client;
  // Ignoriert die Prüfung des SSL-Zertifikats (Secure Sockets Layer). 
  // Für private Bastelprojekte ist das völlig in Ordnung.
  client.setInsecure(); 

  HTTPClient http;
  
  // 2. URL mit dynamischem Dateinamen zusammenbauen
  String dateiName = generiereDateiname();
  String zielUrl = "https://webdav.netcloud.de/remote.php/dav/files/" + String(webdavUser) + "/fotos/" + dateiName;
  
  Serial.println("Starte Upload zu: " + zielUrl);

  // 3. Verbindung starten und Login-Daten übergeben
  http.begin(client, zielUrl);
  http.setAuthorization(webdavUser, webdavPass);

  // 4. Das Bild mit HTTP PUT direkt auf den Server schieben
  int httpAntwortCode = http.PUT(bildDaten, bildGroesse);

  // 5. Auswertung, ob es geklappt hat
  if (httpAntwortCode > 0) {
    Serial.print("Upload erfolgreich! Server meldet Code: ");
    Serial.println(httpAntwortCode); 
    // Code 201 heißt "Created" (Erfolgreich angelegt)
  } else {
    Serial.print("Fehler beim Upload: ");
    Serial.println(http.errorToString(httpAntwortCode).c_str());
  }

  // 6. Verbindung sauber trennen, um Arbeitsspeicher freizugeben
  http.end();
}

Mit diesem Setup hast du die volle Kontrolle über deine Daten. Die Lösung läuft extrem stabil und lässt sich auch auf lokale Netzwerkspeicher (NAS – Network Attached Storage) anwenden, die WebDAV unterstützen.

, ,
Datenschutz-Übersicht

Diese Website verwendet Cookies, damit wir dir die bestmögliche Benutzerkomfort bieten können. Cookie-Informationen werden in deinem Browser gespeichert und führen Funktionen aus, wie das Wiedererkennen von dir, wenn du auf unsere Website zurückkehrst und hilft uns zu verstehen, welche Abschnitte der Website für dich am interessantesten und nützlichsten sind.