ESP32: WiFi Provisioning mit Soft AP und Captive Portal

Inzwischen habe ich schon einige Projekte wie das Zyklochron, die Nebenuhren oder die Aquariumbeleuchtung mit dem ESP32 umgesetzt, da diese Mikrocontroller über eine integrierte WLAN-Schnittstelle verfügen, was die Entwicklung von IoT-Anwendungen enorm vereinfacht. Das Problem für diese "headless" Geräte (Geräte ohne Bildschirm und Tastatur) ist aber immer der "erste Kontakt", also wie kommt der ESP32 in das eigene WLAN. 

Bisher habe ich dafür meine SmartConfig Bibliothek verwendet, die grundsätzlich auch sehr gut funktioniert. Allerdings wird für das Provisioning eine Smartphone App benötigt, die allerdings nicht von Espressif bereitgestellt wird. Es gibt zwar den Sourcecode für diese App sowohl für Android als auch für iPhone auf GitHub, aber scheinbar bevorzugt Espressif selbst eher andere Provisioning Methoden.

Bei diesen Provisioning Methoden unterscheidet man prinzipiell zwei Arten:

In-band Provisioning: Nutzt ausschließlich das WLAN-Funknetzwerk zur Übertragung der Netzinformationen. Hierzu gehören Methoden wie Wi-Fi Protected Setup (WPS), Access Point Mode, und SmartConfig. SmartConfig, eine von Texas Instruments entwickelte Methode, nutzt beispielsweise die Längen von Datenpaketen zur Kodierung der Netzwerkdaten, die dann per UDP gesendet werden.

Out-of-band Provisioning: Verwendet ein anderes Medium, sei es kabelgebunden (z.B. USB) oder drahtlos (z.B. Bluetooth, NFC, oder sogar Lichtsignale/Töne). Mit WPA3 wurde hier auch Wi-Fi Easy Connect (DPP) über QR-Codes oder NFC-Tags eingeführt.

Das SDK von Espressif bietet umfangreiche Unterstützung für das Provisioning an, und im den App-Stores von Apple und Google werden für Bluetooth und SoftAP Provisioning auch entsprechende Apps bereitgestellt.

Ich möchte aber in Zukunft von keiner App mehr abhängig sein, daher scheidet das Out-of-band Provisioning in diesem Fall aus. Auch für SmartConfig ist ein App nötig, daher habe ich mich für die Access Point Methode (SoftAPmit einem Captive Portal entschieden. Dadurch ist es zusätzlich möglich, neben der SSID und dem Passwort auch andere Daten wie zum Beispiel die Zeitzone zu schicken, damit der ESP32 auch gleich die richtige Uhrzeit einstellen kann.

SoftAP mit einem Captive Portal - Funktionsweise

Zur Übertragung von WLAN-Zugangsdaten wird der ESP32 temporär in einen vollwertigen Netzwerk-Host verwandelt. Dieser Prozess wird durch das Zusammenspiel von drei Diensten realisiert, die auf dem Mikrocontroller gestartet werden: ein Access Point (AP), ein DNS-Server und ein Webserver.

Schritt 1: Etablierung des Access Points

Zuerst wird der ESP32 in den Access-Point-Modus versetzt. Anstatt sich mit einem bestehenden Netzwerk zu verbinden, erstellt der ESP32 ein eigenes, lokales WLAN mit einer spezifischen SSID (z. B. "ESP32-Setup"). Dieses Netzwerk ist in der Regel ungesichert, um die Erstverbindung für den Benutzer zu vereinfachen. Jedes WLAN-fähige Gerät kann sich nun direkt mit dem ESP32 verbinden.

Schritt 2: Automatische Umleitung via DNS (Captive Portal)

Nachdem sich ein Gerät mit dem WLAN des ESP32 verbunden hat, findet der entscheidende Schritt statt. Moderne Betriebssysteme (iOS, Android, etc.) führen nach der Verbindung mit einem neuen Netzwerk automatisch eine Verbindungstest durch. Dazu versuchen sie, eine herstellerspezifische Webseite zu erreichen, wie captive.apple.com für Apple-Geräte oder connectivitycheck.gstatic.com für Android.

Hier greift der auf dem ESP32 gestartete DNS-Server ein:

  1. Die Anfrage des Smartphones nach der IP-Adresse von captive.apple.com wird nicht an das Internet, sondern an den DNS-Server des ESP32 gesendet.
  2. Die einzige Aufgabe dieses DNS-Servers ist es, jede Domain-Anfrage abzufangen und mit der IP-Adresse des ESP32 selbst (standardmäßig 192.168.4.1) zu beantworten.

Das Smartphone fragt also: "Wo finde ich captive.apple.com?" und der ESP32 antwortet: "Das bin ich, 192.168.4.1."

Schritt 3: Auslieferung der Konfigurationsseite

Durch die DNS-Umleitung versucht der Browser des Smartphones nun, eine Webseite von der Adresse 192.168.4.1 zu laden. An dieser Stelle übernimmt der dritte Dienst, der Webserver auf dem ESP32. Anstatt der einfachen Textantwort, die das Betriebssystem erwartet, liefert der Webserver eine vollständige HTML-Webseite aus – die Konfigurationsoberfläche. Das Betriebssystem des Smartphones erkennt, dass es sich in einem Captive Portal befindet, und öffnet diese Seite automatisch in einem speziellen Browserfenster.

SoftAP Captive Portal de

Dem Benutzer wird nun eine Webseite zur Auswahl des WLANs, zur Eingabe des Passworts und der Auswahl der Zeitzone angezeigt.

Schritt 4: Empfang und Speicherung der Zugangsdaten

Gibt der Benutzer die SSID sowie das Passwort ein und bestätigt die Eingabe, sendet der Browser eine HTTP-POST-Anfrage an den Webserver des ESP32. Die empfangenen Daten werden aus der Anfrage extrahiert und für die dauerhafte Sicherung im NVS (Non-Volatile Storage), einem speziellen Flash-Speicherbereich des ESP32, abgelegt. So bleiben die Zugangsdaten auch nach einem Neustart erhalten.

Schritt 5: Wechsel zum Client-Modus

Nach der erfolgreichen Speicherung der Daten wird der Zustandswechsel eingeleitet. Die für die Provisionierung gestarteten Dienste werden in umgekehrter Reihenfolge heruntergefahren und das "ESP32-Setup"-WLAN verschwindet wieder. Eine vollständige Deinitialisierung des WiFi-Stacks stellt sicher, dass das System in einen definierten Ausgangszustand zurückgesetzt wird.

Schritt 6: Finale Verbindung

Abschließend wird der WiFi-Stack neu initialisiert, diesmal jedoch im Station-Modus (STA). Der ESP32 liest die im NVS gespeicherten Zugangsdaten aus und verbindet sich mit dem vom Benutzer konfigurierten WLAN. Sobald vom Router eine IP-Adresse via DHCP bezogen wurde, ist der Prozess abgeschlossen, und der Mikrocontroller ist im lokalen Netzwerk einsatzbereit. Sollte die Verbindung nicht funktionieren, wiederholt der ESP32 den Verbindungsversuch einige male und wenn er scheitert, werden die Verbindungsdaten gelöscht und der ESP32 neu gestartet.

Aufbau und Funktionen der Bibliothek

Die WifiProvisioner-Bibliothek ist als C++-Klasse konzipiert, die den gesamten Prozess der Netzwerkkonfiguration kapselt. Sie wurde als reines ESP-IDF Projekt entwickelt, da ich eher selten das Arduino Framework verwende. Die Funktionsweise der Bibliothek lässt sich am besten anhand ihrer zentralen öffentlichen Methoden erklären:

start_provisioning() – Der Konfigurationsprozess

Diese Methode ist der Startpunkt für die Erstkonfiguration. Sie ist blockierend, was bedeutet, dass sie die Ausführung des Programms anhält, bis der Benutzer die WLAN-Daten über das Captive Portal gesendet hat. Intern orchestriert sie das Zusammenspiel aller notwendigen Dienste:

  1. Startet den ESP32 im Access-Point-Modus.
  2. Aktiviert den DNS-Server für die automatische Umleitung (Captive-Effekt).
  3. Aktiviert den Webserver, der die Konfigurationsseite ausliefert.
  4. Wartet auf ein internes Signal vom Webserver, das den Empfang der Daten bestätigt.
  5. Führt nach Erhalt der Daten eine saubere Deinitialisierung aller Dienste durch.
esp_err_t WifiProvisioner::start_provisioning(const std::string& ap_ssid, bool persistent_storage, const std::string& ap_password) {
    _persistent_storage = persistent_storage;

    ESP_LOGI(TAG, "Starting provisioning mode...");
    ESP_ERROR_CHECK(start_ap_(ap_ssid, ap_password));
    start_dns_server();
    ESP_ERROR_CHECK(start_web_server_());

    ESP_LOGI(TAG, "Provisioning running. Waiting for user to submit credentials...");
    
    // Hält die Ausführung an, bis die Daten per Web-Formular empfangen wurden
    xEventGroupWaitBits(_provisioning_event_group, PROV_SUCCESS_BIT,
                        pdTRUE, pdFALSE, portMAX_DELAY);

    ESP_LOGI(TAG, "Credentials received. Shutting down provisioning services.");
    
    // Fährt alle Dienste wieder herunter
    stop_dns_server();
    stop_web_server_();
    shutdown_wifi_(); // Beinhaltet esp_wifi_stop() und esp_wifi_deinit()

    return ESP_OK;
}


is_provisioned() – Die Zustandsprüfung

Diese einfache Hilfsfunktion prüft, ob bereits gültige WLAN-Zugangsdaten im NVS (Non-Volatile Storage) des ESP32 gespeichert sind. Sie ermöglicht es der Hauptanwendung, beim Start zu entscheiden, ob der Konfigurationsprozess gestartet werden muss oder ob direkt eine Verbindung hergestellt werden kann.

bool WifiProvisioner::is_provisioned() {
    nvs_handle_t h;
    if(nvs_open(PROV_NVS_NAMESPACE, NVS_READONLY, &h) != ESP_OK) return false;
    
    size_t required_size = 0;
    // Prüft, ob der Schlüssel "ssid" existiert und eine Länge > 0 hat
    bool key_exists = nvs_get_str(h, "ssid", NULL, &required_size) == ESP_OK && required_size > 1;
    
    nvs_close(h);
    return key_exists;
}


connect_sta() – Der Verbindungsaufbau

Nachdem die Zugangsdaten entweder per Provisioning erhalten oder aus dem NVS geladen wurden, initiiert diese Funktion den eigentlichen Verbindungsaufbau zum Ziel-WLAN.

  1. Initialisiert den WiFi-Stack im Station-Modus.
  2. Konfiguriert den ESP32 mit den zuvor geladenen Zugangsdaten (SSID und Passwort).
  3. Setzt den Hostname des Geräts.
  4. Startet den WiFi-Treiber, der dann im Hintergrund die Verbindung herstellt.
esp_err_t WifiProvisioner::connect_sta(const char* hostname) {
    // Stellt sicher, dass das WiFi-System initialisiert ist
    if (!wifi_initialized_) {
        init_wifi_();
    }

    // Konfiguriert den Hostname für das STA-Interface
    esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
    if (sta_netif) {
        ESP_ERROR_CHECK(esp_netif_set_hostname(sta_netif, hostname));
    }

    // Erstellt die WiFi-Konfiguration mit den Member-Variablen der Klasse
    wifi_config_t wifi_config = {};
    strncpy((char*)wifi_config.sta.ssid, _ssid.c_str(), sizeof(wifi_config.sta.ssid));
    strncpy((char*)wifi_config.sta.password, _password.c_str(), sizeof(wifi_config.sta.password));
    
    // Startet den WiFi-Stack im Station-Modus
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
    ESP_ERROR_CHECK(esp_wifi_start());

    // Wendet die Zeitzone an
    setenv("TZ", _timezone.c_str(), 1);
    tzset();

    return ESP_OK;
}


Das Frontend – HTML und JavaScript

Die gesamte Benutzeroberfläche für den Provisionierungsprozess wird von einer eingebetteten HTML-Datei bereitgestellt. Das Scannen nach Netzwerken und das Absenden der Daten wird durch clientseitiges JavaScript realisiert. Die Interaktion zwischen dem Frontend (Browser des Benutzers) und dem Backend (Webserver auf dem ESP32) basiert auf zwei zentralen Endpunkten: /scan.json zum Abrufen der Netzwerke und /save zum Senden der Konfiguration.

1. Der WLAN-Scan und das Befüllen der Auswahlliste

Sobald die Seite im Browser geladen ist (window.onload), wird sofort eine asynchrone Anfrage an den ESP32 gesendet, um nach verfügbaren WLAN-Netzwerken zu suchen.

  • Ein JavaScript fetch-Request wird an den Endpunkt /scan.json gesendet.
  • Der ESP32 führt einen WLAN-Scan durch und sendet eine JSON-formatierte Liste der gefundenen Netzwerke zurück. Diese Antwort ist ein Array von Objekten, die jeweils die SSID und die Signalstärke (rssi) enthalten.
  • Das JavaScript verarbeitet diese JSON-Antwort. Es löscht die anfängliche "Scanne..."-Nachricht aus der Dropdown-Liste und fügt die gefundenen Netzwerke ein.
// --- WiFi scan logic ---
fetch('/scan.json')
    .then(response => response.json()) // Antwort als JSON interpretieren
    .then(data => {
        // Dropdown-Liste leeren und mit "Bitte auswählen" initialisieren
        ssidSelect.innerHTML = '<option value="">Please select a network</option>';
        
        if (data.aps && data.aps.length > 0) {
            // Für jedes gefundene Netzwerk eine neue Option erstellen
            data.aps.forEach(ap => {
                const option = new Option(ap.ssid, ap.ssid);
                ssidSelect.add(option);
            });
        } else {
            ssidSelect.innerHTML = '<option value="">No networks found</option>';
        }
        // Lade-Animation ausblenden
        loader.style.visibility = 'hidden';
    })
    .catch(error => {
        // Fehlerbehandlung, falls der Scan fehlschlägt
        console.error('Error during WiFi scan:', error);
        ssidSelect.innerHTML = '<option value="">Scan failed</option>';
        loader.style.visibility = 'hidden';
    });


2. Die verknüpfte Zeitzonen-Auswahl

Um eine einfache Auswahl der Zeitzone zu ermöglichen, wird eine zweistufige Logik verwendet: Zuerst wird eine Region (z.B. "Europe") und dann eine spezifische Zeitzone (z.B. "(GMT+01:00) Amsterdam, Berlin...") ausgewählt.

  • Die Liste aller Zeitzonen ist direkt im JavaScript als Array von Objekten hinterlegt. Jedes Objekt enthält eine benutzerfreundliche Beschreibung (description) und den technischen POSIX-String (value).
  • Das Skript füllt zunächst nur die Regionen-Auswahl. Wählt der Benutzer eine Region, filtert das Skript das große Zeitzonen-Array und füllt die zweite Dropdown-Liste nur mit den passenden Einträgen.
  • Der technische POSIX-String, den der ESP32 benötigt, wird im Hintergrund in einem versteckten Eingabefeld (<input type="hidden">) gespeichert und beim Absenden des Formulars mitgesendet.

3. Das Absenden der Konfiguration an /save

Wenn der Benutzer das ausgefüllte Formular abschickt, wird das Standardverhalten des Browsers unterbunden und die Daten werden ebenfalls asynchron per fetch gesendet.

  • Ein Event Listener auf dem submit-Ereignis des Formulars fängt den Klick auf den "Speichern & Verbinden"-Button ab.
  • Mit new FormData(form) werden alle ausgefüllten Formularfelder (SSID, Passwort und der Wert aus dem versteckten Zeitzonen-Feld) automatisch für den Versand vorbereitet.
  • Die Daten werden per HTTP-POST an den /save-Endpunkt auf dem ESP32 gesendet.
  • Nach dem Absenden wird die Schaltfläche deaktiviert. Bei einer erfolgreichen Antwort vom ESP32 wird der gesamte Formularbereich durch eine Erfolgsmeldung ersetzt, die den Benutzer informiert, dass der Prozess abgeschlossen ist und er das Fenster schließen kann.
// --- Form submit logic ---
form.addEventListener('submit', function(event) {
    event.preventDefault(); // Verhindert das Neuladen der Seite
    
    // Sende die Formulardaten an den ESP32
    fetch('/save', {
        method: 'POST',
        body: new URLSearchParams(new FormData(form))
    })
    .then(response => {
        if (response.ok) {
            // Zeige die Erfolgsmeldung an
            mainContainer.innerHTML = `
                <h1>Configuration Received</h1>
                <p>The device will now attempt to connect to the WiFi.</p>`;
        } else { 
            // Zeige eine Fehlermeldung an
            throw new Error('Server response was not OK'); 
        }
    })
    .catch(error => { /* Fehlerbehandlung */ });
});


Konfiguration

Die Dateien für die Benutzeroberfläche pro Sprache (index_xx.html, style.css) werden direkt in die Firmware eingebettet.  

Dies wird in components/wifi_provisioner/CMakeLists.txt konfiguriert:  

# ...
target_add_binary_data(${COMPONENT_TARGET} "web/index_de.html" TEXT)
target_add_binary_data(${COMPONENT_TARGET} "web/style.css"  TEXT)


und in components/wifi_provisioner/wifi_provisioner.cpp:

# ...
extern const char root_html_start[] asm("_binary_index_de_html_start");
extern const char root_html_end[]   asm("_binary_index_de_html_end");


Anwendungsbeispiel

Das nachfolgende Beispiel zeigt, wie diese Funktionen in einer typischen app_main-Funktion zusammenspielen, um den Startprozess zu implementieren:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <time.h> 
#include "wifi_provisioner.hpp"

static const char* TAG = "MAIN_APP";

extern "C" void app_main(void) {
    ESP_LOGI(TAG, "Application starting...");

    WifiProvisioner provisioner;

    // SCHRITT 1: Entscheiden, ob eine Konfiguration nötig ist
    if (provisioner.is_provisioned()) {
        // Option A: Gerät ist bereits konfiguriert. Lade Daten aus dem NVS.
        provisioner.get_credentials();
    } else {
        // Option B: Gerät ist neu. Starte den Provisionierungs-Prozess.
        provisioner.start_provisioning("ESP32-Setup", true);
    }

    // SCHRITT 2: Verbinden
    // Diese Funktion nutzt die Daten, die in Schritt 1 entweder geladen oder neu empfangen wurden.
    provisioner.connect_sta("Mein-ESP32");

    // Endlosschleife für die eigentliche Hauptanwendung
    ESP_LOGI(TAG, "Main application logic can now run. Waiting for WiFi events...");
    while(true) {
        time_t now;
        struct tm timeinfo;
        char strftime_buf[64];

        time(&now);
        localtimer(&now, &timeinfo);

        // Prüfen, ob die Zeit vom NTP-Server bereits synchronisiert wurde
        if (timeinfo.tm_year < 100) {
            ESP_LOGI(TAG, "Zeit ist noch nicht synchronisiert.");
        } else {
            strftime(strftime_buf, sizeof(strftime_buf), "%A, %d. %B %Y %H:%M:%S", &timeinfo);
            ESP_LOGI(TAG, "Aktuelle lokale Zeit: %s", strftime_buf);
        }
        
        vTaskDelay(pdMS_TO_TICKS(1000));
    } 
}


Fazit

Dieser Artikel ist recht ausführlich geworden, aber ich habe versucht, alles so genau wir möglich zu erklären, damit ich den Code später auch noch verstehen werde :-)

In nächster Zeit werde ich diese Provisioning-Methode schrittweise in meine ESP32-Projekte integrieren, um ihre Stabilität und Alltagstauglichkeit zu testen. Der Source Code für die Bibliothek liegt wie immer auf GitHub. Bei Fragen und Anmerkungen hinterlasst mir einfach eine Nachricht. 

Konversation wird geladen