DIY Fotoapparat mit ESP32-S3, das die Bilder direkt in dein Google Drive 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 (Lithium-Polymer-Akkumulator), der unser Projekt zusätzlich mobil machen würde.
  • 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“ zwingend auf Jeder. (Nur temporär öffnen und auf eigene Gefahr)
  • 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> 

// --- 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; // Ist D3
const int ledPin = 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...");
  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; 
  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;
  }
  Serial.println("Kamera ist bereit. Warte auf Knopfdruck.");
}

void loop() {
  if (digitalRead(tasterPin) == LOW) {
    digitalWrite(ledPin, HIGH);
    Serial.println("Klick! Mache ein Foto...");

    camera_fb_t * bildspeicher = esp_camera_fb_get();
    if (!bildspeicher) {
      Serial.println("Fehler bei der Aufnahme.");
      digitalWrite(ledPin, LOW);
      return;
    }

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

      // Wir werten Code 200 (OK) oder Code 302 (Umleitung nach Skript-Ausführung) als Erfolg
      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.");
    }

    esp_camera_fb_return(bildspeicher);
    digitalWrite(ledPin, LOW);
    
    // Eine kurze Pause, damit nicht aus Versehen mehrere Bilder gemacht werden
    delay(2000); 
  }
}

Ergebnis:

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


3D Druck:

hier der Openscad Skript. 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();

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

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

Viel Spaß beim Tüfteln und Nachbauen!

Youtube Video: