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:
- Der ESP32 versucht beim Start, sich mit gespeicherten Zugangsdaten zu verbinden.
- 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“.
- Du verbindest dich mit dem Handy. Ein DNS-Server auf dem ESP32 beantwortet jede Namensanfrage mit seiner eigenen IP.
- Dein Handy prüft nach dem Verbinden automatisch, ob Internet vorhanden ist – bekommt die ESP32-Seite zurück und öffnet sie von selbst.
- 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
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:
- ESP32 WiFi Bluetooth Entwicklungsboard (WROOM32, USB-C)
- ESP32-C3 Mini oder Wemos D1 Mini V4 als kompakte Alternative
- 0,96 Zoll OLED SSD1306 – für die Statusanzeige
- TTP223 Touch-Sensor (10er-Set) – als Reset-Taster
- DHT22 (AM2302) Temperatur- und Feuchtigkeitssensor
- ESP32 Acrylgehäuse – für den fertigen Aufbau
- Breadboard 400 Kontakte und Dupont-Jumperkabel
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.