ESP32 WLAN-Zugangsdaten ohne Code-Änderung: Captive Portal & WiFiManager selbst bauen

Zwei Zeilen, die in fast jedem ESP32-Sketch stehen: const char* ssid = "..."; und const char* pass = "...";. Solange das Gerät auf deinem Schreibtisch liegt, ist das bequem. Sobald du den Router wechselst, das Gerät in die Garage hängst oder es einem Freund schenkst, wird daraus ein Problem – dann brauchst du Laptop, USB-Kabel und die Arduino IDE, nur um ein Passwort zu ändern. In diesem Tutorial baust du die Lösung selbst: ein Captive Portal, wie du es aus Hotel-WLANs kennst.

Wie ein Captive Portal funktioniert

Der Ablauf ist immer derselbe, egal ob bei einer Smart-Home-Steckdose oder deinem selbstgebauten Sensor:

  1. Der ESP32 versucht beim Start, sich mit gespeicherten Zugangsdaten zu verbinden.
  2. Klappt das nicht (oder es gibt noch keine), schaltet er in den Access-Point-Modus und spannt ein eigenes WLAN auf, zum Beispiel „MAKEROO-Setup“.
  3. Du verbindest dich mit dem Handy. Ein DNS-Server auf dem ESP32 beantwortet jede Namensanfrage mit seiner eigenen IP.
  4. Dein Handy prüft nach dem Verbinden automatisch, ob Internet vorhanden ist – bekommt die ESP32-Seite zurück und öffnet sie von selbst.
  5. Du wählst dein Netz aus der Liste, tippst das Passwort ein, der ESP32 speichert beides dauerhaft und startet neu.

ℹ️ Warum das automatische Öffnen funktioniert. Android ruft nach dem Verbinden connectivitycheck.gstatic.com/generate_204 auf, iOS captive.apple.com/hotspot-detect.html. Erwartet wird eine ganz bestimmte Antwort. Kommt stattdessen eine normale HTML-Seite, geht das Betriebssystem von einem Portal aus und zeigt es an. Genau dieses Verhalten nutzt du aus – mehr Magie steckt nicht dahinter.

Das brauchst du

ESP32 Entwicklungsboard (WROOM32, USB-C) – das Hauptboard für dieses Tutorial
ESP32-C3 Mini oder Wemos D1 Mini (ESP8266) – funktionieren ebenfalls
TTP223 Touch-Sensor – als Reset-Taster für die Zugangsdaten
0,96 Zoll OLED SSD1306 – zeigt Portal-Name und spätere IP-Adresse an
DHT22 (AM2302) Temperatursensor – als Beispielnutzlast für den fertigen Sensor
ESP32 Acrylgehäuse – wenn das Gerät am Ende irgendwohin soll

1Verkabelung

Bauteil Pin ESP32 (WROOM32) ESP32-C3 Mini
OLED SSD1306 SDA / SCL GPIO21 / GPIO22 GPIO8 / GPIO9
OLED SSD1306 VCC / GND 3V3 / GND 3V3 / GND
TTP223 Touch SIG GPIO4 GPIO3
DHT22 DATA GPIO27 GPIO10
Status-LED (onboard) GPIO2 GPIO8 (je nach Board)

Für den reinen Portal-Teil brauchst du übrigens gar keine Peripherie – der ESP32 allein reicht. OLED und Touch machen das Ergebnis nur alltagstauglicher.

2Zugangsdaten dauerhaft speichern mit Preferences

Bevor das Portal Sinn ergibt, brauchst du einen Ort für die Daten. Auf dem ESP32 heißt der Preferences – eine Schlüssel-Wert-Ablage im NVS-Bereich des Flash, die einen Neustart und sogar ein neues Sketch-Upload übersteht:

#include <Preferences.h>

Preferences prefs;

void speichereZugang(const String& ssid, const String& pass) {
  prefs.begin("wlan", false);        // false = les- und schreibbar
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.end();
}

bool ladeZugang(String& ssid, String& pass) {
  prefs.begin("wlan", true);         // true = nur lesen
  ssid = prefs.getString("ssid", "");
  pass = prefs.getString("pass", "");
  prefs.end();
  return ssid.length() > 0;
}

void loescheZugang() {
  prefs.begin("wlan", false);
  prefs.clear();                     // gesamten Namensraum leeren
  prefs.end();
}

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

  String s, p;
  if (ladeZugang(s, p)) {
    Serial.printf("Gespeichert: %s\n", s.c_str());
  } else {
    Serial.println("Noch keine Zugangsdaten hinterlegt");
  }
}

void loop() {}

ℹ️ Auf dem ESP8266 geht das anders. Der Wemos D1 Mini kennt keine Preferences. Nutze dort stattdessen LittleFS oder die klassische EEPROM-Emulation. Beide Wege beschreibt der Artikel Arduino EEPROM & ESP32 Preferences im Detail.

⚠️ Das Passwort liegt im Klartext im Flash. Wer den Chip physisch in die Hand bekommt und ausliest, kann es lesen. Für ein Heimnetz ist das in der Praxis akzeptabel – sei dir aber bewusst, dass du kein Sicherheitsprodukt baust. Verschenkst du ein Gerät, lösche die Daten vorher über den Reset-Taster aus Schritt 5.

3Das komplette Captive Portal

Jetzt der Hauptteil. Dieser Sketch braucht keine externe Bibliothek – alles Benötigte steckt bereits im ESP32-Core. Er scannt verfügbare Netze, zeigt sie sortiert nach Signalstärke an und fängt jede DNS-Anfrage ab:

#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>

#define AP_NAME    "MAKEROO-Setup"
#define AP_PASS    ""          // leer = offenes Setup-Netz
#define LED_PIN    2
#define VERBINDUNGS_TIMEOUT 20000UL

WebServer  server(80);
DNSServer  dns;
Preferences prefs;

bool portalAktiv = false;

// ---------- Speicher ----------
bool ladeZugang(String& ssid, String& pass) {
  prefs.begin("wlan", true);
  ssid = prefs.getString("ssid", "");
  pass = prefs.getString("pass", "");
  prefs.end();
  return ssid.length() > 0;
}

void speichereZugang(const String& ssid, const String& pass) {
  prefs.begin("wlan", false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.end();
}

// ---------- Verbindungsversuch ----------
bool verbinde(const String& ssid, const String& pass) {
  Serial.printf("Verbinde mit %s ...\n", ssid.c_str());
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid.c_str(), pass.c_str());

  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED &&
         millis() - start < VERBINDUNGS_TIMEOUT) {
    digitalWrite(LED_PIN, !digitalRead(LED_PIN));   // blinken
    delay(250);
  }

  if (WiFi.status() == WL_CONNECTED) {
    digitalWrite(LED_PIN, HIGH);
    Serial.print("Verbunden! IP: ");
    Serial.println(WiFi.localIP());
    return true;
  }
  digitalWrite(LED_PIN, LOW);
  Serial.println("Verbindung fehlgeschlagen");
  return false;
}

// ---------- Konfigurationsseite ----------
String baueSeite() {
  int n = WiFi.scanNetworks();

  String html = F(
    "<!DOCTYPE html><html lang='de'><head><meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>WLAN einrichten</title><style>"
    "body{font-family:system-ui,sans-serif;background:#101520;color:#e0e6ed;"
    "margin:0;padding:24px;}"
    "h1{font-size:22px;margin-bottom:4px;}"
    "p.sub{color:#8b95a5;font-size:14px;margin-bottom:20px;}"
    "label{display:block;margin:14px 0 6px;font-size:14px;}"
    "select,input{width:100%;padding:12px;border-radius:8px;border:1px solid #252e42;"
    "background:#141b2d;color:#e0e6ed;font-size:16px;box-sizing:border-box;}"
    "button{width:100%;margin-top:22px;padding:14px;border:0;border-radius:8px;"
    "background:#3b82f6;color:#fff;font-size:16px;font-weight:600;}"
    "</style></head><body>"
    "<h1>WLAN einrichten</h1>"
    "<p class='sub'>Waehle dein Netzwerk und gib das Passwort ein.</p>"
    "<form action='/speichern' method='POST'>"
    "<label>Netzwerk</label><select name='ssid'>");

  for (int i = 0; i < n; i++) {
    html += "<option value='" + WiFi.SSID(i) + "'>";
    html += WiFi.SSID(i);
    html += " (" + String(WiFi.RSSI(i)) + " dBm";
    html += (WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? ", offen" : "";
    html += ")</option>";
  }

  html += F("</select>"
    "<label>Passwort</label>"
    "<input type='password' name='pass' placeholder='WLAN-Passwort'>"
    "<button type='submit'>Speichern und verbinden</button>"
    "</form></body></html>");

  WiFi.scanDelete();
  return html;
}

void handleRoot()    { server.send(200, "text/html; charset=utf-8", baueSeite()); }
void handleNotFound(){ server.sendHeader("Location", "/", true);
                       server.send(302, "text/plain", ""); }

void handleSpeichern() {
  String ssid = server.arg("ssid");
  String pass = server.arg("pass");

  if (ssid.length() == 0) {
    server.send(400, "text/plain; charset=utf-8", "Kein Netzwerk gewaehlt");
    return;
  }

  speichereZugang(ssid, pass);
  server.send(200, "text/html; charset=utf-8",
    "<meta charset='utf-8'><body style='font-family:sans-serif;background:#101520;"
    "color:#e0e6ed;padding:24px'><h2>Gespeichert</h2>"
    "<p>Das Geraet startet jetzt neu und verbindet sich.</p></body>");

  delay(1500);
  ESP.restart();
}

// ---------- Portal starten ----------
void startePortal() {
  portalAktiv = true;
  WiFi.mode(WIFI_AP_STA);            // AP + Scan gleichzeitig moeglich
  WiFi.softAP(AP_NAME, strlen(AP_PASS) ? AP_PASS : nullptr);
  delay(200);

  IPAddress ip = WiFi.softAPIP();
  Serial.print("Portal laeuft unter ");
  Serial.println(ip);

  dns.setErrorReplyCode(DNSReplyCode::NoError);
  dns.start(53, "*", ip);            // JEDE Domain auf uns umleiten

  server.on("/", handleRoot);
  server.on("/speichern", HTTP_POST, handleSpeichern);
  server.onNotFound(handleNotFound);
  server.begin();
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  delay(300);

  String ssid, pass;
  if (ladeZugang(ssid, pass) && verbinde(ssid, pass)) {
    Serial.println("Normalbetrieb startet");
    // hier deine eigentliche Anwendung initialisieren
  } else {
    startePortal();
  }
}

void loop() {
  if (portalAktiv) {
    dns.processNextRequest();
    server.handleClient();
    return;
  }

  // ---- Normalbetrieb ----
  // deine Sensorlogik hier
  delay(10);
}

💡 So testest du es: Sketch hochladen, seriellen Monitor öffnen. Beim ersten Start erscheint „Noch keine Zugangsdaten“ und das Netz „MAKEROO-Setup“ taucht auf dem Handy auf. Verbinde dich – innerhalb weniger Sekunden öffnet sich das Formular von selbst. Passiert nichts, rufe einfach http://192.168.4.1 im Browser auf.

4Status auf dem OLED anzeigen

Ein Gerät ohne Anzeige ist im Fehlerfall eine Blackbox. Mit einem SSD1306 siehst du sofort, ob das Portal läuft und welche IP das Gerät später im Heimnetz hat:

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define OLED_BREITE 128
#define OLED_HOEHE   64
Adafruit_SSD1306 oled(OLED_BREITE, OLED_HOEHE, &Wire, -1);

void oledInit() {
  Wire.begin(21, 22);
  if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("OLED nicht gefunden (Adresse 0x3C pruefen)");
    return;
  }
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
}

void zeigePortal(const char* apName, IPAddress ip) {
  oled.clearDisplay();
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.println(F("SETUP-MODUS"));
  oled.drawFastHLine(0, 10, 128, SSD1306_WHITE);
  oled.setCursor(0, 16);
  oled.println(F("WLAN verbinden mit:"));
  oled.setCursor(0, 28);
  oled.println(apName);
  oled.setCursor(0, 44);
  oled.println(F("Dann im Browser:"));
  oled.setCursor(0, 54);
  oled.println(ip);
  oled.display();
}

void zeigeVerbunden(const String& ssid, IPAddress ip, int rssi) {
  oled.clearDisplay();
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.println(F("VERBUNDEN"));
  oled.drawFastHLine(0, 10, 128, SSD1306_WHITE);
  oled.setCursor(0, 18);
  oled.println(ssid);
  oled.setCursor(0, 32);
  oled.print(F("IP: "));
  oled.println(ip);
  oled.setCursor(0, 46);
  oled.print(F("Signal: "));
  oled.print(rssi);
  oled.println(F(" dBm"));

  // kleine Signalbalken rechts oben
  int balken = map(constrain(rssi, -90, -50), -90, -50, 0, 4);
  for (int i = 0; i < 4; i++) {
    int h = (i + 1) * 2;
    if (i < balken) oled.fillRect(100 + i * 6, 8 - h, 4, h, SSD1306_WHITE);
    else            oled.drawRect(100 + i * 6, 8 - h, 4, h, SSD1306_WHITE);
  }
  oled.display();
}

Diese Funktionen rufst du einfach an den passenden Stellen auf: zeigePortal(AP_NAME, WiFi.softAPIP()) am Ende von startePortal(), zeigeVerbunden(...) nach erfolgreicher Verbindung.

5Reset-Taster: Zugangsdaten wieder löschen

Der wichtigste Komfortbaustein. Ohne ihn brauchst du beim Router-Wechsel doch wieder das USB-Kabel. Hier mit einem TTP223 Touch-Sensor, der fünf Sekunden gehalten werden muss – versehentliches Auslösen ist damit praktisch ausgeschlossen:

#define TOUCH_PIN 4
#define HALTEZEIT 5000UL

unsigned long beruehrtSeit = 0;
bool warBeruehrt = false;

void pruefeReset() {
  bool beruehrt = digitalRead(TOUCH_PIN) == HIGH;

  if (beruehrt && !warBeruehrt) {
    beruehrtSeit = millis();                 // Beruehrung beginnt
    warBeruehrt = true;
  }
  else if (!beruehrt) {
    warBeruehrt = false;
    beruehrtSeit = 0;
  }
  else if (warBeruehrt && millis() - beruehrtSeit > HALTEZEIT) {
    Serial.println(F("Zugangsdaten werden geloescht ..."));

    // schnelles Blinken als Bestaetigung
    for (int i = 0; i < 10; i++) {
      digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      delay(80);
    }

    prefs.begin("wlan", false);
    prefs.clear();
    prefs.end();

    delay(300);
    ESP.restart();
  }
}

// In setup() ergaenzen:
//   pinMode(TOUCH_PIN, INPUT);
// In loop() als erste Zeile:
//   pruefeReset();

ℹ️ Kein Touch-Sensor zur Hand? Der ESP32 kann Touch auch ohne Zusatzmodul: touchRead(T0) auf GPIO4 liefert einen Wert, der beim Berühren eines angelöteten Drahtstücks deutlich fällt. Ein einfacher Taster gegen GND mit INPUT_PULLUP tut es natürlich genauso.

6Reconnect: Was passiert, wenn der Router neu startet?

Ein Gerät, das nach einem Router-Neustart still stehen bleibt, ist im Alltag nutzlos. Der ESP32 bringt dafür ab Core 2.x eine eingebaute Automatik mit – mit einer zusätzlichen Sicherung für den Fall, dass gar nichts mehr geht:

#define RECONNECT_INTERVALL 30000UL
#define MAX_FEHLVERSUCHE 10

unsigned long letzterCheck = 0;
uint8_t fehlversuche = 0;

void setupWiFiAuto() {
  WiFi.setAutoReconnect(true);       // ESP32 versucht selbst neu zu verbinden
  WiFi.persistent(true);
  WiFi.setSleep(false);              // stabiler bei Serverbetrieb
}

void pruefeVerbindung() {
  if (millis() - letzterCheck < RECONNECT_INTERVALL) return;
  letzterCheck = millis();

  if (WiFi.status() == WL_CONNECTED) {
    fehlversuche = 0;
    return;
  }

  fehlversuche++;
  Serial.printf("WLAN weg (Versuch %u von %u)\n", fehlversuche, MAX_FEHLVERSUCHE);
  WiFi.disconnect();
  WiFi.reconnect();

  // Nach vielen Fehlversuchen: Portal wieder oeffnen
  if (fehlversuche >= MAX_FEHLVERSUCHE) {
    Serial.println(F("Dauerhaft keine Verbindung - Portal wird geoeffnet"));
    startePortal();
  }
}

// In loop() ergaenzen:
//   if (!portalAktiv) pruefeVerbindung();

⚠️ Vorsicht bei batteriebetriebenen Geräten. Ein ESP32, der alle 30 Sekunden vergeblich einen Verbindungsversuch startet, leert einen Akku in wenigen Tagen. Läuft dein Sensor mit einem TP4056-Lademodul und einer 18650-Zelle, verlängere das Intervall deutlich und kombiniere das Ganze mit Deep Sleep.

Selbst bauen oder WiFiManager nehmen?

Die bekannte Bibliothek WiFiManager von tzapu macht im Kern genau das, was du gerade gebaut hast. Sie ist ausgereift und in zwei Zeilen eingebunden. Trotzdem lohnt sich die Entscheidung bewusst:

Kriterium Selbst gebaut WiFiManager-Bibliothek
Codezeilen im Sketch ca. 150 ca. 5
Flash-Bedarf gering deutlich höher
Aussehen anpassbar vollständig nur über Parameter
Eigene Felder (MQTT-Server o. Ä.) frei ergänzbar über Custom Parameters
Verständnis der Mechanik komplett Blackbox
Wartungsaufwand bei dir beim Projekt

Meine Empfehlung: Bau es einmal selbst – danach verstehst du jede Fehlermeldung. Für Serienprojekte, in denen du zusätzlich MQTT-Broker, Gerätename und Intervalle konfigurierbar machen willst, ist die Bibliothek dann eine gute Abkürzung. Wie du die Zugangsdaten anschließend für MQTT und Home Assistant nutzt, zeigt der passende Artikel.

Troubleshooting

⚠️ Das Portal öffnet sich auf dem Handy nicht automatisch: Sehr häufig, wenn das Handy zusätzlich mobile Daten aktiv hat. Deaktiviere sie kurz oder rufe http://192.168.4.1 manuell auf. Wichtig: http://, nicht https:// – der ESP32 liefert kein Zertifikat.

⚠️ Der Netzwerk-Scan bleibt leer: Im reinen WIFI_AP-Modus kann der ESP32 nicht scannen. Nutze WIFI_AP_STA, wie im Beispiel oben.

⚠️ Verbindung schlägt trotz korrektem Passwort fehl: Der ESP32 unterstützt nur das 2,4-GHz-Band. Bei Routern mit gemeinsamem Namen für 2,4 und 5 GHz hilft es, das 2,4-GHz-Netz mit eigenem Namen zu betreiben. Auch Sonderzeichen und Leerzeichen in der SSID machen gelegentlich Ärger.

⚠️ Nach dem Speichern startet das Gerät in einer Endlosschleife neu: Meist ein Brownout. Der ESP32 zieht beim Verbindungsaufbau kurzzeitig über 300 mA. Nutze ein besseres Netzteil oder löte einen Elko mit 470 µF zwischen 3V3 und GND.

⚠️ prefs.clear() löscht scheinbar nichts: clear() wirkt nur im geöffneten Namensraum und nur, wenn dieser im Schreibmodus geöffnet wurde – also prefs.begin("wlan", false).

⚠️ Die Umlaute auf der Konfigurationsseite sind kaputt: Der Content-Type muss text/html; charset=utf-8 lauten und das Meta-Tag <meta charset='utf-8'> enthalten sein. Am einfachsten ist es, in der Weboberfläche ganz auf Umlaute zu verzichten.

⚠️ Das Setup-Netz verschwindet nach ein paar Minuten: Manche Sketche setzen einen Portal-Timeout. Willst du das bewusst – etwa damit ein Gerät nicht dauerhaft ein offenes WLAN aufspannt – baue nach fünf Minuten ohne Eingabe ein ESP.restart() ein.

🚀 Bauteile für dein konfigurierbares WLAN-Gerät

Alles aus diesem Tutorial:

Fazit

Ein Captive Portal ist der Unterschied zwischen einem Bastelprojekt und einem Gerät, das man tatsächlich benutzt. Die Zutaten sind überschaubar: Preferences für die dauerhafte Speicherung, WIFI_AP_STA für Hotspot und Scan gleichzeitig, ein DNS-Server, der jede Anfrage auf sich selbst umleitet, und ein schlankes HTML-Formular.

Zwei Ergänzungen machen aus dem Prototyp ein Alltagsgerät: der Reset-Taster für den Router-Wechsel und eine Reconnect-Logik, die nach mehreren Fehlversuchen das Portal von selbst wieder öffnet. Ab jetzt liegt kein Passwort mehr in deinem Sketch – und du kannst deine Projekte problemlos verschenken oder mitnehmen.

Zurück zum Blog