Async WebServer mit dem ESP32

Über den Beitrag

In diesem Beitrag erfahrt ihr, wie ihr mit ESP32-Boards einen asynchronen Webserver (Async WebServer) realisiert. Die ersten Schritte sind unkompliziert, doch sobald ihr den Server zum Steuern oder Abfragen von Zuständen nutzen wollt, entstehen neue Herausforderungen. Richtig interessant wird es dann, wenn mehrere ESP32 miteinander kommunizieren sollen und die Oberfläche zugleich ansprechend gestaltet werden soll.

Folgendes erwartet euch:

Async WebServer vs. klassischer WebServer

Vielleicht hat der eine oder andere meinen Beitrag WLAN mit ESP8266 und ESP32 gelesen. Dort habe ich einen einfachen, klassischen Webserver verwendet, um ESP8266- und ESP32-Boards zu steuern. Wieso also noch ein Beitrag über den Async WebServer? 

Ein wesentlicher Unterschied zwischen den beiden Webserver-Varianten ist, dass der klassische WebServer im Gegensatz zum Async WebServer blockierend arbeitet. Das veranschaulicht das folgende Schema: 

Klassicher WebServer vs. Async WebServer
Klassicher WebServer vs. Async WebServer

Beim klassischen Webserver steckt der Server in loop() und wird kontinuierlich mit server.handleClient() abgefragt. Bei einem Client-Request (z. B. greift der Browser des PC auf den Webserver zu) wird die Anfrage verarbeitet und dann die Antwort gesendet. Währenddessen ist das Programm blockiert, was zu Problemen führen kann, wenn andere zeitabhängige Aufgaben erledigt werden sollen. 

Der Async WebServer hingegen arbeitet eventbasiert. Bekommt er eine Abfrage, wird er nur sehr kurz aus seinen laufenden Aufgaben herausgerissen und initiiert die Bearbeitung des Requests (Callback-Funktion) in einem separaten Task.  

Bei einfachen Anwendungen, also beispielsweise nur einem Client und wenigen Aufgaben, die pro Zeiteinheit erledigt werden müssen, spricht nichts gegen den klassischen WebServer. Ansonsten solltet ihr eher zum Async WebServer greifen. 

Vorbereitungen

Als Boardpaket für den ESP32 verwende ich „esp32“ von Espressif. Hier geht es zur Installation. Als Boards kamen ESP-WROOM-32-basierte Development-Boards zum Einsatz. Grundsätzlich sollten die Beispiele dieses Beitrags mit allen ESP32-Boards funktionieren.  

Für den Async WebServer verwende ich die Bibliotheken ESP Async WebServer und Async TCP, beide von ESP32Async. In der Arduino IDE könnt ihr diese bequem über den Bibliotheksmanager installieren.

Einstieg: „Hello World“

Bare Minimum

Wir starten mit einem einfachen Sketch, der euch ein „Hello World“ in den Browser zaubert. Passt ihn an, indem ihr den Namen eures WLANs und das Passwort eintragt (Kommentar: TODO).

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

// insert your credentials
constexpr char WIFI_SSID[] = "Your SSID";      // TODO
constexpr char WIFI_PASS[] = "Your password";  // TODO

AsyncWebServer server(80);

void connectWiFiSTA() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  Serial.print("Verbinde mit WLAN ");
  Serial.print(WIFI_SSID);
  Serial.print(" ... ");

  while (WiFi.status() != WL_CONNECTED) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  
  Serial.print("Connected, IP: ");
  Serial.println(WiFi.localIP()); 
}

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

  connectWiFiSTA();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(200, "text/html", "Hello World");
  });

  server.begin();
  Serial.println("Server started.");
}

void loop() {}

Im seriellen Monitor wird euch die vom Router vergebene IP-Adresse angezeigt. Wenn im seriellen Monitor nichts erscheint, dann drückt den Resetknopf auf dem Board oder fügt ein delay() von ein paar Sekunden am Anfang von setup() ein. 

Ausgabe von async_webserver_hello_world_bare_minimum.ino
Ausgabe von async_webserver_hello_world_bare_minimum.ino

Dann geht in den Browser eurer Wahl und tippt die IP in die Adresszeile ein. Daraufhin sollte ein schlichtes „Hello World“ erscheinen. 

Erklärungen zum „Bare Minimum Sketch“

  • AsyncWebServer server(80) erzeugt den Webserver, der über den Port 80 zugänglich ist.
  • Mit WiFi.mode(WIFI_STA)⁣ stellt ihr den Station-Modus ein. In diesem Modus klinkt sich der ESP32 in ein WLAN-Netzwerk ein und erhält von diesem seine IP-Adresse. Später kommen wir auf den AP-Modus (= Access Point) zu sprechen, bei dem der ESP32 selbst das WLAN-Netzwerk bereitstellt.
  • WiFi.begin() übergebt ihr den Namen des Netzwerks und das Passwort.
  • WiFi.status() != WL_CONNECTED ist false, solange keine Verbindung zum Netzwerk besteht. 

Erklärungen zu server.on()

Der Aufruf von server.on() ist für viele wahrscheinlich etwas ungewohnt: 

server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
  request->send(200, "text/html", "Hello World");
});

Das ist – im Prinzip – die Kurzform von:

void handleRoot(AsyncWebServerRequest *request) {
  request->send(200, "text/html", "Hello World");
}

void setup() {
  ...
  server.on("/", HTTP_GET, handleRoot);
}

In der Kurzform ist der dritte Parameter von server.on(), also [](AsyncWebServerRequest *request) {...} eine Lambda-Funktion. Sie wird auch als anonyme Funktion bezeichnet, da sie keinen Namen hat. Lambda-Funktionen werden direkt an die Stelle geschrieben, an der sie verwendet werden, anstatt sie separat zu definieren. Das ist vor allem kurz und flexibel. Mehr dazu würde an dieser Stelle zu weit führen. 

Damit ist das Wesentliche zum Async WebServer eigentlich schon gesagt! Alles Weitere dreht sich vorwiegend um das Ersetzen von „Hello World“ durch Text, HTML-, CSS- und JavaScript-Code.

Erweiterter Hello World – Sketch

Bevor wir mit dem eigentlichen Thema weitermachen, möchte ich einige nützliche Erweiterungen des „Hello World“-Sketches vorstellen.

  1. Mit WiFi.config(ip, gateway, subnet); gebt ihr die IP-Adresse vor. Das tue ich in den weiteren Sketchen. Die Funktion muss vor WiFi.begin() aufgerufen werden. 
  2. Wenn euch der Aufruf der IP-Adresse zu kryptisch ist, dann könnt ihr mit MDNS.begin(HOSTNAME) einen schöneren Namen vergeben. Ihr müsst dazu die Bibliothek ESPmDNS einbinden. Die Website ruft ihr dann mit http://HOSTNAME.local auf. 
  3. server.onNotFound() definiert, was passiert, wenn der Pfad nicht gefunden wird. Hier könnt ihr die 404 übergeben.
  4. Wenn sich der ESP32 nicht mit dem Netzwerk verbinden will, kann es Sinn machen, ihn neu zu starten. Genau das tut der Sketch. 

Ich denke, alles Weitere ist selbsterklärend. Hier der Sketch:

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ESPmDNS.h>

IPAddress ip(192,168,178,112);  //TODO: IP address of your choice
IPAddress gateway(192,168,178,1); //TODO: Router address
IPAddress subnet(255,255,255,0); //TODO: subnet mask, probably OK as is.

// insert your credentials
constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO
constexpr char HOSTNAME[] = "MyESP32-Server";  //TODO: Server name of your choice

AsyncWebServer server(80);

void connectWiFiSTA() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  Serial.print("Verbinde mit WLAN ");
  Serial.print(WIFI_SSID);
  Serial.print(" ... ");

  // wait max 10 seconds
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("Connected, IP: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("WiFi Connection failed (timeout). Restart in 5s …");
    delay(5000);
    ESP.restart();
  }

  if (!MDNS.begin(HOSTNAME)) {
    Serial.println("Error starting mDNS");
    return;
  }
  // MDNS.addService("http", "tcp", 80); // may be needed
  Serial.print("MDNS responder started at http://");
  Serial.print(HOSTNAME);
  Serial.println(".local");
}

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

  connectWiFiSTA();

  // Route: GET /
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(200, "text/html", "Hello World");
  });

  // Optional: 404
  server.onNotFound([](AsyncWebServerRequest *request) {
    request->send(404, "text/html", "Not found");
  });

  server.begin();
  Serial.println("Server started.");
}

void loop() {}

 

Auf dem seriellen Monitor seht ihr eine Ausgabe wie diese:

Async Webserver Ausgabe von hello_world_extended.ino
Ausgabe von hello_world_extended.ino

Eine LED per Browser schalten

Im nächsten Schritt werden wir eine LED an GPIO2 des ESP32 über den Browser ein- und ausschalten. Ich nehme bewusst nur eine LED und verwende noch kein CSS, um mit diesem einfachen Sketch das Prinzip zu verdeutlichen. Hier zunächst der Sketch:

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID"; //TODO
constexpr char WIFI_PASS[] = "Your Password";  //TODO

const int ledPin = 2;
bool ledOn = false;

AsyncWebServer server(80);

void setLed(bool on) {
  ledOn = on;
  digitalWrite(ledPin, on);
}

String page() {
  if (ledOn) {
    return String(F("<a href=\"/off\">Switch LED off</a>"));
  } else {
    return String(F("<a href=\"/on\">Switch LED on</a>"));
  }
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Connecting to %s", WIFI_SSID);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250); Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: "); Serial.println(WiFi.localIP());
}

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);
  setLed(false);

  connectWiFi();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html", page());
  });

  server.on("/on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.begin();
  Serial.println("Server ready.");
}

void loop() {}

Und so sah die Ausgabe in meinem Browser aus:

Async WebServer - Browserausgabe von switch_one_led.ino
Browserausgabe von switch_one_led.ino

Erklärungen zu switch_one_led.ino

Bitte habt Verständnis dafür, dass dieser Beitrag keine Lehrstunde in HTML, CSS und JavaScript ist. Es würde schlicht zu weit gehen, wenn ich darauf im Detail einginge. Ich konzentriere mich auf den Arduino Code.

Die Variable ledOn speichert den Zustand der LED, nämlich false für aus und true für an. Im Grundzustand ist die LED aus. 

Im Browser erfolgt die Steuerung der LED über Links. Der erste Aufruf der Website über die IP-Adresse führt in den Hauptpfad „/“. Es greift also server.on("/", .... Die Sendefunktion req->send() überträgt an den Client:

  • Den HTTP-Statuscode 200 → alles OK.
  • „text/html“ → interpretiere die Nachricht als HTML.
  • Einen String, den die Funktion page() zurückgibt. Da ledOn true ist, wird der Link zu „/off“ gesendet.

Im Browser seht ihr den Link-Text „Switch LED Off“. Klickt ihr nun auf diesen Link, greift server.on("/off", ...). Die Lambda-Funktion ruft zunächst die Funktion setLed(false) auf, die ledOn auf false setzt und die LED ausschaltet. req->send() ruft wieder die Funktion page() auf. Da ledOn dieses Mal false ist, wird der Link zu „/on“ zurückgegeben. Im Browser erscheint der Link-Text „Switch LED on“. Und so könnt ihr jetzt munter hin und her schalten. 

Alternativ zu req->send(200, "text/html", page()) könntet ihr mit req->redirect("/") in den Hauptpfad zurückkehren, wodurch wiederum req->send() aufgerufen wird.  

Drei LEDs + PWM + analogRead

Wir steigern uns, indem wir drei LEDs schalten, eine LED per PWM dimmen und die Spannung an einem Pin ermitteln. Dabei bleiben immer noch bei (fast) reinem HTML-Code. Schöner und anwenderfreundlicher machen wir es später. 

Schaltung für die folgenden Sketche
Schaltung für die folgenden Sketche

Schalten / Steuern der LEDs

Drei LEDs zu schalten, ist eigentlich nur etwas mehr Schreibarbeit. Das Prinzip bleibt dasselbe, nur legen wir die LED-Pins und den LED-Status (ledOn) als Array an. Nach wie vor bekommt jeder Schaltvorgang ein eigenes Verzeichnis, also /led0_on, /led0_off, /led1_on usw.

Den PWM-Wert übergeben wir mit einem Formular (<form>...</form>). Da ich die PWM‑Auflösung auf 255 festgelegt habe, hätte es hier auch ein analogWrite() getan. So könnt ihr den Code aber auch auf andere PWM-Anwendungen übertragen.

Abruf und Darstellung des analogRead()-Wertes

Die Spannung an einem Pin zu ermitteln und auszugeben, ist einfach. Hier stellt sich aber die Frage, wie man die Spannung automatisch aktualisieren kann. Im Prinzip könnt ihr die ganze Seite periodisch neu laden, indem ihr <html><head><meta http-equiv='refresh' content='5'></head> (für 5 Sekunden) am Anfang der Webseite einfügt. Das beißt sich jedoch mit der PWM-Eingabe. Wenn der Refresh mitten in der Eingabe erfolgt, dann ist euer Eingabefeld wieder leer.

Die Lösung lautet iframe (= inline frame). Das ist sozusagen eine in die Webseite eingebettete, weitere Webseite. Wenn ihr den Refresh nur auf die eingebettete Webseite anwendet, dann stört das den Rest nicht. 

Hier aber erst einmal der Sketch:

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

const int ledPin[] = {2,4,18};
const int analogReadPin = 34;
const int pwmLedPin = 5;    
int pwmValue = 0;
bool ledOn[] = {false, false, false};

AsyncWebServer server(80);

void setLed(int num, bool on) {
  ledOn[num] = on;
  digitalWrite(ledPin[num], on);
}

String page() {
  String myPage = "";
  for(int i=0; i<3; i++){
    if (ledOn[i]) {
      myPage += String(F("<a href=\"/led"));
      myPage += String(i);
      myPage += String(F("_off\">Switch LED "));
      myPage += String(i);
      myPage += String(F(" off</a></BR></BR>"));
    } else {
      myPage += String(F("<a href=\"/led"));
      myPage += String(i);
      myPage += String(F("_on\">Switch LED "));
      myPage += String(i);
      myPage += String(F(" on</a></BR></BR>"));
    }
  }
  
  myPage += "</BR><form action='/set'>";
  myPage += "PWM: <input name='value' type='number' min='0' max='255'>";
  myPage += "<input type='submit' value='Submit'>";
  myPage += "</form>";
  myPage += "Current PWM value: ";
  myPage += String(pwmValue); 

  myPage += "</BR></BR></BR>";
  myPage += "<iframe src='/value' width='200' height='40' frameborder='0'></iframe></BR></BR>";
  
  return myPage;
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Connecting to %s", WIFI_SSID);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250); Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: "); Serial.println(WiFi.localIP());
}

void setup() {
  Serial.begin(115200);
  
  for(int i=0; i<3; i++) {
    pinMode(ledPin[i], OUTPUT);
    setLed(i, false);
  }
  pinMode(analogReadPin, INPUT);
  ledcAttach(pwmLedPin, 5000, 8);

  connectWiFi();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html", page());
  });

  server.on("/led0_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led0_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led1_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led1_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led2_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led2_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/set", HTTP_GET, [](AsyncWebServerRequest *request){
    if (request->hasParam("value")) {
      pwmValue = request->getParam("value")->value().toInt();
      pwmValue = constrain(pwmValue, 0, 255);
      ledcWrite(pwmLedPin, pwmValue);
      request->send(200, "text/html", page());
      // req->redirect("/"); // alternative to the line above
    }
  });
  
  server.on("/value", HTTP_GET, [](AsyncWebServerRequest *req){
    float voltage = 3.3 * analogRead(analogReadPin) / 4096.0;
    String html = "";
    html += "<html><head><meta http-equiv='refresh' content='5'></head>";
    html += "<body style='margin:0; padding:0; text-align:left;'>";
    html += "Voltage [V]: ";
    html += String(voltage, 2);
    html += "</body></html>";

  req->send(200, "text/html", html);
  });
    
  server.begin();
  Serial.println("Server ready.");
}

void loop() {}

Einen Punkt sollte ich aber noch erklären, und zwar, wie es der PWM-Wert aus dem Formular in den Request schafft.

Mit request->hasParam("value") prüft der Webserver, ob im HTTP-Request ein Parameter mit dem Namen value enthalten ist, der vom Eingabefeld des HTML-Formulars stammt. request->getParam("value")->value() liest den vom Benutzer eingegebenen Text aus diesem Formularfeld aus. toInt() wandelt diesen Text anschließend in eine Ganzzahl um.

Und so sieht die Ausgabe in meinem Browser aus:

Async WebServer - Browserausgabe von switch_three_leds_plus_pwm_plus voltage.ino
Browserausgabe von switch_three_leds_plus_pwm_plus voltage.ino

Schöner mit CSS

HTML bietet viele Gestaltungsmöglichkeiten. Dadurch kann der Code jedoch schnell unübersichtlich werden. Die Lösung dafür ist CSS (Cascading Style Sheets).

CSS-Definitionen werden, getrennt vom eigentlichen Seiteninhalt (<body>...</body>), im Kopfbereich des Webdokuments (<head>...</head>) innerhalb eines eigenen Abschnitts (<style>...</style>) oder in einer externen Datei abgelegt.

Grundsätzlich kann man mit CSS vor allem zwei Dinge tun:

  1. Globale Einstellungen für bestehende HTML-Elemente vornehmen:
    • z. B. für die h1-Überschrift: h1 { font-size: 24px; margin: 0 0 10px 0; text-align: center; }
      Der Vorteil: Die Definition muss nur einmal erfolgen und gilt dann für alle entsprechenden Elemente.
  2. Eigene Klassen definieren:
    • z. B. eine Unterüberschrift: .subtitle { text-align: center; font-size: 13px; color: #666; margin-bottom: 20px; }

Die Klassen können anschließend in HTML-Elementen verwendet werden, z. B.: <div class="subtitle">...</div>. Auch das Kombinieren mehrerer Klassen ist möglich.

Ihr kennt euch mit CSS nicht aus und es fehlt die Zeit und Lust, euch einzuarbeiten? Kein Problem in Zeiten künstlicher Intelligenz. Auch ich selbst habe für die optische Ausgestaltung ChatGPT benutzt. ChatGPT mag die eine oder andere Lücke bei Wissensfragen haben, aber meine Erfahrung mit dieser KI zum Programmieren von HTML, CSS und JavaScript ist ausgesprochen gut. Trotzdem ist es natürlich immer besser, man hat zumindest Grundkenntnisse. 

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your Password";  //TODO

const int ledPin[] = {2,4,18};
const int analogReadPin = 34;
const int pwmLedPin = 5;    
int pwmValue = 0;
bool ledOn[] = {false, false, false};

AsyncWebServer server(80);

void setLed(int num, bool on) {
  ledOn[num] = on;
  digitalWrite(ledPin[num], on);
}

String page() {
  String myPage = "";

  // head + CSS + container start
  myPage += F(
    "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
    "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
    "<title>ESP32 LED &amp; PWM Control</title>"
    "<style>"
    "body{font-family:Arial,Helvetica,sans-serif;background:#f0f2f5;margin:0;padding:0;}"
    ".container{max-width:600px;margin:40px auto;padding:20px;background:#ffffff;"
    "border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);}"
    "h1{font-size:24px;margin:0 0 10px 0;text-align:center;}"
    ".subtitle{text-align:center;font-size:13px;color:#666;margin-bottom:20px;}"
    ".section{margin:20px 0;}"
    ".section-title{font-weight:bold;margin-bottom:8px;font-size:15px;color:#333;}"
    ".led-grid{display:flex;gap:12px;justify-content:center;}"
    ".led-tile{flex:1;padding:18px 10px;text-align:center;border-radius:10px;"
    "font-weight:bold;text-decoration:none;color:#ffffff;"
    "transition:background-color 0.2s,transform 0.1s,box-shadow 0.1s;"
    "box-shadow:0 2px 4px rgba(0,0,0,0.1);font-size:14px;}"
    ".led-on{background:#e53935;}"
    ".led-on:hover{background:#b71c1c;}"
    ".led-off{background:#43a047;}"
    ".led-off:hover{background:#1b5e20;}"
    ".led-tile:active{transform:translateY(1px);box-shadow:0 1px 2px rgba(0,0,0,0.2);}"
    ".pwm-form{display:flex;flex-direction:column;gap:8px;}"
    ".pwm-form label{font-size:14px;color:#333;}"
    ".pwm-form input[type='number']{width:100%;padding:8px 10px;border-radius:6px;"
    "border:1px solid #ccc;box-sizing:border-box;font-size:14px;}"
    ".pwm-form input[type='submit']{align-self:flex-start;padding:8px 14px;border:none;"
    "border-radius:6px;cursor:pointer;background:#1976d2;color:#ffffff;font-weight:bold;font-size:14px;"
    "transition:background-color 0.2s;}"
    ".pwm-form input[type='submit']:hover{background:#0d47a1;}"
    ".pwm-value{margin-top:6px;font-size:14px;color:#333;}"
    ".voltage-box{background:#1565c0;border-radius:10px;padding:8px 10px;}"
    ".voltage-box iframe{border:0;width:100%;height:50px;}"
    "</style>"
    "</head><body><div class='container'>"
    "<h1>ESP32 LED &amp; PWM Control</h1>"
    "<div class='subtitle'>Control LEDs, set LED brightness and display voltage</div>"
    "<div class='section'><div class='section-title'>LEDs</div>"
    "<div class='led-grid'>"
  );

  // LED icons
  for(int i=0; i<3; i++){
    if (ledOn[i]) {
      myPage += String(F("<a href=\"/led"));
      myPage += String(i);
      myPage += String(F("_off\" class='led-tile led-on'>LED "));
      myPage += String(i);
      myPage += String(F(" is ON</a>"));
    } else {
      myPage += String(F("<a href=\"/led"));
      myPage += String(i);
      myPage += String(F("_on\" class='led-tile led-off'>LED "));
      myPage += String(i);
      myPage += String(F(" is OFF</a>"));
    }
  }

  myPage += F("</div></div>"); // Ende LED-Section

  // PWM section
  myPage += F(
"<div class='section'><div class='section-title'>PWM for LED (Pin 5)</div>"
"<form class='pwm-form' action='/set'>"
"<label for='pwmInput'>PWM value (0 – 255):</label>"
"<input id='pwmInput' name='value' type='number' min='0' max='255' placeholder='e.g. 128'>"
"<input type='submit' value='Set PWM'>"
"</form>"
  );
  myPage += F("<div class='pwm-value'>Current PWM value: ");
  myPage += String(pwmValue);
  myPage += F("</div></div>");

  // voltage section
  myPage += F(
"<div class='section'><div class='section-title'>Voltage at Pin 34</div>"
"<div class='voltage-box'>"
"<iframe src='/value'></iframe>"
"</div></div>"
  );

  // page end
  myPage += F("</div></body></html>");

  return myPage;
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Connecting to %s", WIFI_SSID);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250); Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: "); Serial.println(WiFi.localIP());
}

void setup() {
  Serial.begin(115200);
  
  for(int i=0; i<3; i++) {
    pinMode(ledPin[i], OUTPUT);
    setLed(i, false);
  }
  pinMode(analogReadPin, INPUT);
  ledcAttach(pwmLedPin, 5000, 8);

  connectWiFi();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html", page());
  });

  server.on("/led0_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led0_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led1_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led1_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led2_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, true);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/led2_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, false);
    req->send(200, "text/html", page());
    // req->redirect("/"); // alternative to the line above
  });

  server.on("/set", HTTP_GET, [](AsyncWebServerRequest *request){
    if (request->hasParam("value")) {
      pwmValue = request->getParam("value")->value().toInt();
      pwmValue = constrain(pwmValue, 0, 255);
      ledcWrite(pwmLedPin, pwmValue);
      request->send(200, "text/html", page());
      // req->redirect("/"); // alternative to the line above
    }
  });
  
  server.on("/value", HTTP_GET, [](AsyncWebServerRequest *req){
    float voltage = 3.3 * analogRead(analogReadPin) / 4096.0;
    String html = "";
    html += "<html><head><meta http-equiv='refresh' content='5'>";
    html += "<meta charset='UTF-8'><style>";
    html += "body{margin:0;font-family:Arial,Helvetica,sans-serif;}";
    html += ".voltage-box{background:#1565c0;color:#ffffff;padding:8px 10px;";
    html += "display:flex;align-items:center;justify-content:space-between;";
    html += "height:100%;box-sizing:border-box;font-size:14px;}";
    html += ".label{font-weight:bold;margin-right:8px;}";
    html += "</style></head>";
    html += "<body><div class='voltage-box'><span class='label'>Voltage [V]:</span><span>";
    html += String(voltage, 2);
    html += "</span></div></body></html>";

    req->send(200, "text/html", html);
  });
    
  server.begin();
  Serial.println("Server ready.");
}

void loop() {}

Hier das Ergebnis:

Async WebServer - Browserausgabe von nicer_with_css.ino
Browserausgabe von nicer_with_css.ino

Bequemer mit JavaScript

Neben HTML und CSS gibt es für die Webseitengestaltung noch JavaScript. Wie der Name es erahnen lässt, handelt es sich um eine Skriptsprache. Mit JavaScript wird die Interaktion mit der Webseite noch bequemer und effektiver. Ich nutze JavaScript im folgenden Sketch für die LED-Buttons und ersetze die Eingabe des PWM-Wertes durch einen Slider. JavaScript-Code wird innerhalb eines <script>...</script>-Elements eingefügt.

Ein schöner Effekt des JavaScript-Codes ist, dass in dieser Version die Seite nicht immer wieder neu aufgebaut werden muss. Da, wo vorher die ganze Seite zurückgegeben wurde: req->send(200, "text/html", page());, wird in dieser Version lediglich ein OK gesendet: req->send(200, "text/plain", "OK");. Um das Update der Slider und Buttons kümmert sich JavaScript.

Auch hier gilt: kein Problem, wenn ihr euch auch nicht mit JavaScript auskennt. ChatGPT kann das für euch übernehmen. Auch ich habe nur Grundkenntnisse in JavaScript. 

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

const int ledPin[] = {2, 4, 18};
const int analogReadPin = 34;
const int pwmLedPin = 5;
int pwmValue = 0;
bool ledOn[] = {false, false, false};

AsyncWebServer server(80);

void setLed(int num, bool on) {
  ledOn[num] = on;
  digitalWrite(ledPin[num], on);
}

String page() {
  String myPage;

  myPage += F(
    "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
    "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
    "<title>ESP32 LED &amp; PWM Control</title>"
    "<style>"
    "body{font-family:Arial,Helvetica,sans-serif;background:#f0f2f5;margin:0;padding:0;}"
    ".container{max-width:600px;margin:40px auto;padding:20px;background:#ffffff;"
      "border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);}"
    "h1{font-size:24px;margin:0 0 10px 0;text-align:center;}"
    ".subtitle{text-align:center;font-size:13px;color:#666;margin-bottom:20px;}"
    ".section{margin:20px 0;}"
    ".section-title{font-weight:bold;margin-bottom:8px;font-size:15px;color:#333;}"
    ".led-grid{display:flex;gap:12px;justify-content:center;}"
    ".led-button{flex:1;padding:18px 10px;text-align:center;border-radius:10px;"
      "font-weight:bold;border:none;cursor:pointer;color:#ffffff;"
      "transition:background-color 0.2s,transform 0.1s,box-shadow 0.1s;"
      "box-shadow:0 2px 4px rgba(0,0,0,0.1);font-size:14px;}"
    ".led-on{background:#e53935;}"
    ".led-on:hover{background:#b71c1c;}"
    ".led-off{background:#43a047;}"
    ".led-off:hover{background:#1b5e20;}"
    ".led-button:active{transform:translateY(1px);box-shadow:0 1px 2px rgba(0,0,0,0.2);}"
    ".pwm-form{display:flex;flex-direction:column;gap:8px;}"
    ".pwm-form label{font-size:14px;color:#333;}"
    ".pwm-form input[type='range']{width:100%;}"
    ".voltage-box{background:#1565c0;border-radius:10px;padding:8px 10px;}"
    ".voltage-box iframe{border:0;width:100%;height:50px;}"
    "</style>"
    "</head><body><div class='container'>"
    "<h1>ESP32 LED &amp; PWM Control</h1>"
    "<div class='subtitle'>Control LEDs, set LED brightness and display voltage</div>"
    "<div class='section'><div class='section-title'>LEDs</div>"
    "<div class='led-grid'>"
  );

  // LED Buttons
  for (int i = 0; i < 3; i++) {
    myPage += "<button type='button' class='led-button ";
    if (ledOn[i]) {
      myPage += "led-on";
    } else {
      myPage += "led-off";
    }
    myPage += "' data-index='";
    myPage += String(i);
    myPage += "' data-state='";
    myPage += (ledOn[i] ? "1" : "0");
    myPage += "'>";
    myPage += "LED ";
    myPage += String(i);
    myPage += (ledOn[i] ? " is ON" : " is OFF");
    myPage += "</button>";
  }

  myPage += F("</div></div>"); // Ende LED-Section

  // PWM section (Slider)
  myPage += F(
    "<div class='section'><div class='section-title'>PWM for LED (Pin 5)</div>"
    "<div class='pwm-form'>"
    "<label for='pwmSlider'>PWM value (0 – 255): <span id='pwmValueLabel'></span></label>"
  );

  myPage += "<input id='pwmSlider' type='range' min='0' max='255' value='";
  myPage += String(pwmValue);
  myPage += "'>";

  myPage += F("</div></div>");

  // Voltage section (iframe)
  myPage += F(
    "<div class='section'><div class='section-title'>Voltage at Pin 34</div>"
    "<div class='voltage-box'>"
    "<iframe src='/value'></iframe>"
    "</div></div>"
  );

  // JavaScript for buttons & slider
  myPage += F(
    "<script>"
    "document.addEventListener('DOMContentLoaded', function(){"
      "// LED Buttons\n"
      "var ledButtons = document.querySelectorAll('.led-button');"
      "ledButtons.forEach(function(btn){"
        "btn.addEventListener('click', function(){"
          "var index = btn.getAttribute('data-index');"
          "var state = btn.getAttribute('data-state') === '1';"
          "var url = '/led' + index + '_' + (state ? 'off' : 'on');"
          "fetch(url)"
            ".then(function(){"
              "var newState = !state;"
              "btn.setAttribute('data-state', newState ? '1' : '0');"
              "btn.classList.toggle('led-on', newState);"
              "btn.classList.toggle('led-off', !newState);"
              "btn.textContent = 'LED ' + index + (newState ? ' is ON' : ' is OFF');"
            "})"
            ".catch(function(err){console.error(err);});"
        "});"
      "});"

      "// PWM Slider\n"
      "var slider = document.getElementById('pwmSlider');"
      "var valueLabel = document.getElementById('pwmValueLabel');"
      "if(slider && valueLabel){"
        "var updateLabel = function(v){"
          "valueLabel.textContent = v;"
        "};"
        "updateLabel(slider.value);"
        "slider.addEventListener('input', function(e){"
          "updateLabel(e.target.value);"
        "});"
        "slider.addEventListener('change', function(e){"
          "var v = e.target.value;"
          "fetch('/set?value=' + v)"
            ".catch(function(err){console.error(err);});"
        "});"
      "}"
    "});"
    "</script>"
  );

  myPage += F("</div></body></html>");

  return myPage;
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Connecting to %s", WIFI_SSID);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());
}

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

  for (int i = 0; i < 3; i++) {
    pinMode(ledPin[i], OUTPUT);
    setLed(i, false);
  }
  pinMode(analogReadPin, INPUT);

  // PWM initialization
  ledcAttach(pwmLedPin, 5000, 8);   

  connectWiFi();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html", page());
  });

  // LED endpoint: only "OK" is send, no complete refresh
  server.on("/led0_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, true);
    req->send(200, "text/plain", "OK");
  });

  server.on("/led0_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(0, false);
    req->send(200, "text/plain", "OK");
  });

  server.on("/led1_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, true);
    req->send(200, "text/plain", "OK");
  });

  server.on("/led1_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(1, false);
    req->send(200, "text/plain", "OK");
  });

  server.on("/led2_on", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, true);
    req->send(200, "text/plain", "OK");
  });

  server.on("/led2_off", HTTP_GET, [](AsyncWebServerRequest *req){
    setLed(2, false);
    req->send(200, "text/plain", "OK");
  });

  // PWM endpoint, now for the slider
  server.on("/set", HTTP_GET, [](AsyncWebServerRequest *request){
    if (request->hasParam("value")) {
      pwmValue = request->getParam("value")->value().toInt();
      pwmValue = constrain(pwmValue, 0, 255);
      ledcWrite(pwmLedPin, pwmValue);
      request->send(200, "text/plain", "OK");
    } else {
      request->send(400, "text/plain", "Missing value");
    }
  });

  // Voltage page, kept iframe
  server.on("/value", HTTP_GET, [](AsyncWebServerRequest *req){
    float voltage = 3.3 * analogRead(analogReadPin) / 4096.0;
    String html = "";
    html += "<html><head><meta http-equiv='refresh' content='5'>";
    html += "<meta charset='UTF-8'><style>";
    html += "body{margin:0;font-family:Arial,Helvetica,sans-serif;}";
    html += ".voltage-box{background:#1565c0;color:#ffffff;padding:8px 10px;";
    html += "display:flex;align-items:center;justify-content:space-between;";
    html += "height:100%;box-sizing:border-box;font-size:14px;}";
    html += ".label{font-weight:bold;margin-right:8px;}";
    html += "</style></head>";
    html += "<body><div class='voltage-box'><span class='label'>Voltage [V]:</span><span>";
    html += String(voltage, 2);
    html += "</span></div></body></html>";

    req->send(200, "text/html", html);
  });

  server.begin();
  Serial.println("Server ready.");
}

void loop() {}

Hier das Ergebnis:

Browserausgabe von more_convenient_with_script.ino
Browserausgabe von more_convenient_with_script.ino

Alternative Variante des Sketches mit JavaScript

Oder möchtet ihr eine andere Form der LED-Buttons? Das macht der nächste Sketch. Außerdem habe ich hier die Methode des Voltage-Updates geändert. Anstelle des iframes kommt hier JavaScript mit einer „fetch“-Funktion zum Einsatz. Das Update-Intervall wird mit setInterval(updateVoltage, 5000); gesetzt. 

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your Password";  //TODO

const int ledPin[] = {2, 4, 18};
const int analogReadPin = 34;
const int pwmLedPin = 5;
int pwmValue = 0;
bool ledOn[] = {false, false, false};

AsyncWebServer server(80);

void setLed(int num, bool on) {
  ledOn[num] = on;
  digitalWrite(ledPin[num], on);
}

String page() {
  String myPage;

  myPage += F(
    "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
    "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
    "<title>ESP32 LED &amp; PWM Control</title>"
    "<style>"
    "body{font-family:Arial,Helvetica,sans-serif;background:#f0f2f5;margin:0;padding:0;}"
    ".container{max-width:600px;margin:40px auto;padding:20px;background:#ffffff;"
      "border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);}"
    "h1{font-size:24px;margin:0 0 10px 0;text-align:center;}"
    ".subtitle{text-align:center;font-size:13px;color:#666;margin-bottom:20px;}"
    ".section{margin:20px 0;}"
    ".section-title{font-weight:bold;margin-bottom:8px;font-size:15px;color:#333;}"

    /* LED rows + switches */
    ".led-list{display:flex;flex-direction:column;gap:10px;}"
    ".led-row{display:flex;align-items:center;justify-content:space-between;"
      "padding:10px 12px;border-radius:8px;background:#f7f7f7;}"
    ".led-label{font-size:14px;font-weight:bold;color:#333;}"

    /* LED switch toggle */
    ".switch{position:relative;display:inline-block;width:46px;height:24px;}"
    ".switch input{opacity:0;width:0;height:0;}"
    ".slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;"
      "background-color:#ccc;transition:.2s;border-radius:24px;}"
    ".slider:before{position:absolute;content:'';height:18px;width:18px;"
      "left:3px;bottom:3px;background-color:white;transition:.2s;border-radius:50%;}"
    "input:checked + .slider{background-color:#43a047;}"      /* ON color */
    "input:checked + .slider:before{transform:translateX(22px);}"

    ".pwm-form{display:flex;flex-direction:column;gap:8px;}"
    ".pwm-form label{font-size:14px;color:#333;}"
    ".pwm-form input[type='range']{width:100%;}"

    ".voltage-box{background:#1565c0;border-radius:10px;padding:8px 10px;"
      "color:#ffffff;display:flex;align-items:center;justify-content:space-between;}"
    ".label{font-weight:bold;margin-right:8px;}"
    "</style>"
    "</head><body><div class='container'>"
    "<h1>ESP32 LED &amp; PWM Control</h1>"
    "<div class='subtitle'>Control LEDs, set LED brightness and display voltage</div>"
    "<div class='section'><div class='section-title'>LEDs</div>"
    "<div class='led-list'>"
  );

  for (int i = 0; i < 3; i++) {
    myPage += "<div class='led-row'>";
    myPage += "<span class='led-label'>LED ";
    myPage += String(i);
    myPage += "</span>";
    myPage += "<label class='switch'>";
    myPage += "<input type='checkbox' class='led-toggle' data-index='";
    myPage += String(i);
    myPage += "'";
    if (ledOn[i]) {
      myPage += " checked";
    }
    myPage += ">";
    myPage += "<span class='slider'></span>";
    myPage += "</label>";
    myPage += "</div>";
  }

  myPage += F("</div></div>"); // end LED switches

  // PWM section (Slider)
  myPage += F(
    "<div class='section'><div class='section-title'>PWM for LED (Pin 5)</div>"
    "<div class='pwm-form'>"
    "<label for='pwmSlider'>PWM value (0 – 255): <span id='pwmValueLabel'></span></label>"
  );

  myPage += "<input id='pwmSlider' type='range' min='0' max='255' value='";
  myPage += String(pwmValue);
  myPage += "'>";

  myPage += F("</div></div>");

  // Voltage section (JSON + fetch)
  myPage += F(
    "<div class='section'><div class='section-title'>Voltage at Pin 34</div>"
    "<div class='voltage-box'>"
      "<span class='label'>Voltage [V]:</span>"
      "<span id='voltageValue'>--.--</span>"
    "</div></div>"
  );

  // JavaScript for LEDs, PWM slider & voltage update
  myPage += F(
    "<script>"
    "document.addEventListener('DOMContentLoaded', function(){"
      "// LED Toggles\n"
      "var toggles = document.querySelectorAll('.led-toggle');"
      "toggles.forEach(function(cb){"
        "cb.addEventListener('change', function(){"
          "var index = cb.getAttribute('data-index');"
          "var state = cb.checked ? 1 : 0;"
          "fetch('/api/led?index=' + index + '&state=' + state)"
            ".then(function(res){"
              "if(!res.ok) throw new Error('LED request failed');"
            "})"
            ".catch(function(err){"
              "console.error(err);"
              "cb.checked = !cb.checked;"  
            "});"
        "});"
      "});"

      "// PWM Slider\n"
      "var slider = document.getElementById('pwmSlider');"
      "var valueLabel = document.getElementById('pwmValueLabel');"
      "if(slider && valueLabel){"
        "var updateLabel = function(v){"
          "valueLabel.textContent = v;"
        "};"
        "updateLabel(slider.value);"
        "slider.addEventListener('input', function(e){"
          "updateLabel(e.target.value);"
        "});"
        "slider.addEventListener('change', function(e){"
          "var v = e.target.value;"
          "fetch('/set?value=' + v)"
            ".catch(function(err){console.error(err);});"
        "});"
      "}"

      "// Voltage via JSON + fetch\n"
      "var voltageSpan = document.getElementById('voltageValue');"
      "function updateVoltage(){"
        "fetch('/api/voltage')"
          ".then(function(res){"
            "if(!res.ok) throw new Error('Voltage request failed');"
            "return res.json();"
          "})"
          ".then(function(data){"
            "if(voltageSpan && typeof data.voltage === 'number'){"
              "voltageSpan.textContent = data.voltage.toFixed(2);"
            "}"
          "})"
          ".catch(function(err){console.error(err);});"
      "}"
      "updateVoltage();"
      "setInterval(updateVoltage, 5000);"
    "});"
    "</script>"
  );

  myPage += F("</div></body></html>");

  return myPage;
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Connecting to %s", WIFI_SSID);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());
}

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

  for (int i = 0; i < 3; i++) {
    pinMode(ledPin[i], OUTPUT);
    setLed(i, false);
  }
  pinMode(analogReadPin, INPUT);

  ledcAttach(pwmLedPin, 5000, 8);

  connectWiFi();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html", page());
  });

  // generic LED endpoint: /api/led?index=0&state=1
  server.on("/api/led", HTTP_GET, [](AsyncWebServerRequest *req){
    if (req->hasParam("index") && req->hasParam("state")) {
      int index = req->getParam("index")->value().toInt();
      int state = req->getParam("state")->value().toInt();
      if (index >= 0 && index < 3) {
        setLed(index, state != 0);
        req->send(200, "text/plain", "OK");
      } else {
        req->send(400, "text/plain", "Invalid index");
      }
    } else {
      req->send(400, "text/plain", "Missing index or state");
    }
  });

  // PWM endpoint 
  server.on("/set", HTTP_GET, [](AsyncWebServerRequest *request){
    if (request->hasParam("value")) {
      pwmValue = request->getParam("value")->value().toInt();
      pwmValue = constrain(pwmValue, 0, 255);
      ledcWrite(pwmLedPin, pwmValue);
      request->send(200, "text/plain", "OK");
    } else {
      request->send(400, "text/plain", "Missing value");
    }
  });

  // JSON endpoint for voltage
  server.on("/api/voltage", HTTP_GET, [](AsyncWebServerRequest *req){
    float voltage = 3.3 * analogRead(analogReadPin) / 4096.0;
    String json = "{\"voltage\":";
    json += String(voltage, 3);
    json += "}";
    req->send(200, "application/json", json);
  });

  server.begin();
  Serial.println("Server ready.");
}

void loop() {}

Hier noch die Ausgabe im Browser:

Async WebServer - Browserausgabe von alternative_sketch_with_script.ino
Browserausgabe von alternative_sketch_with_script.ino

Wie ihr seht, hat das Ganze nicht mehr so viel mit dem eigentlichen Thema, dem Async WebServer, zu tun. Die Komplexität der letzten Sketche ist vielmehr auf ihren HTML-, CSS- und JavaScript-Inhalt zurückzuführen.

Mehrere ESP32 steuern

Wenn ihr mehrere ESP32 steuern wollt, dann könntet ihr für jeden einzelnen von ihnen eine Webseite erstellen. Das wäre aber ziemlich unkomfortabel. Um alles auf einer Webseite zu vereinen, setzt ihr eines der ESP32-Boards als Master ein. Die anderen ESP32-Boards (die „Worker“) arbeiten dem Master zu. Der Master übernimmt aber auch Workeraufgaben und hat deswegen eine Doppelrolle. In Bezug auf das Gerät, das die Webseite darstellt, ist der Master-ESP32 der Server. 

Es gibt verschiedene Wege, wie die Worker und der Master miteinander kommunizieren. Folgende möchte ich vorstellen:

  1. Die Worker sind Async WebServer. Entsprechend ist der Master in Bezug auf die Worker ein Client.
  2. Kommunikation zwischen Master und Client über ESP-NOW Serial (siehe Beitrag). Mit ESP-NOW (siehe Beitrag) geht es genauso, aber ESP-NOW Serial ist etwas einfacher zu bedienen und reicht für diesen Zweck.
    1. Alle Kommunikation geht über den Router. Die ESP32-Boards arbeiten im STA-Modus und befinden sich im Netzwerk des Routers.
    2. Der Master-ESP32 arbeitet im kombinierten STA- und AP-Modus. Er klinkt sich in das Netzwerk des Routers ein, stellt aber auch selbst ein Netzwerk zur Verfügung, in das sich die Worker einklinken. Die Worker „sehen“ nur den Master-ESP32.

Um das Augenmerk auf die in diesem Abschnitt wesentlichen Aspekte zu richten, verzichte ich bei den folgenden Beispielen auf CSS und JavaScript. Das könnt ihr dann selbst nach euren Wünschen schick machen – oder zieht eine KI zurate. Außerdem schalten wir nur eine LED pro Board. PWM-Steuerung und Spannungsmessung behalten wir bei. Auf eine automatische Aktualisierung des Spannungswertes verzichten wir hier.

Ganz am Ende des Beitrags gibt es dann noch einmal eine schickere Variante mit CSS, JavaScript und automatischem Refresh. 

Option 1: Alles über Async WebServer

Bei dieser Variante ist Master-ESP32 ein Server für den PC und ein Client für die Worker. Alle ESP32-Boards bekommen ihre IP-Adresse vom Router. 

Drei ESP32, Kommunikation per HTTP (mithilfe des Async WebServer)
Drei ESP32, Kommunikation per HTTP (mithilfe des Async WebServer)

Nun brauchen wir drei Sketche, nämlich einen für den Master/Worker 1 und jeweils einen für Worker 2 und Worker 3, wobei sich letztere nur geringfügig unterscheiden. 

Konzept des Sketches für den Master/Worker 1

Das Schalten und Dimmen der LEDs und die Ermittlung der Spannung auf dem Master/Worker 1 sind im Prinzip nichts Neues. Ein Klick auf die entsprechenden Links und das Absenden des PWM-Wertes lösen die Funktionen setLocalLed(), setLocalPwm() und readLocalVoltage() aus. 

Interessanter wird es bei der Steuerung der Worker 2 und 3. Ein Klick auf die Links zum Schalten der LEDs und das Absenden eines PWM-Wertes führen zu der Helper-Funktion callWorkerSimple(url). Die URL setzt sich zusammen aus der IP des aufgerufenen Workers, dem relevanten Verzeichnis und dem Wert.

Die Spannungswerte werden zwei Sekunden in loop() abgefragt. Dazu wird die Funktion readAllVoltages() ausgeführt. Innerhalb dieser Funktion werden die Spannungen der Worker über die Helper-Funktion readWorker(url) ermittelt. Hier besteht die URL aus der IP des Workers und dem Voltage-Verzeichnis. Die Aktualisierung der Messwerte im Browser erfolgt allerdings erst, wenn ihr auf den Link „Refresh page“ klickt.

Das, was unser Browser automatisch macht, wenn wir eine Website aufrufen, müssen wir hier nun „zu Fuß“ machen, nämlich einen GET-Request senden. Das übernehmen die Helper-Funktionen. Damit der Master aber überhaupt einen GET-Request senden kann, muss er zum Client werden. Dazu wird ein HTTPClient-Objekt erzeugt. Auch das übernehmen die Helper-Funktionen.

Konzept des Sketches für den Worker 2 und Worker 3

Die Sketche für Worker 2 und Worker 3 enthalten nichts Neues. Sie erzeugen einen Async-Webserver, der auf die Anfragen des Clients reagiert. Sie selbst kümmern sich nicht um die visuelle Darstellung im Browser, sondern schalten und dimmen die LED und liefern den Spannungswert zurück. 

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <HTTPClient.h>

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

// IP addresses of the two worker ESP32 boards
const char* ESP2_IP = "192.168.178.113";  //TODO
const char* ESP3_IP = "192.168.178.114";  //TODO

// Local pins on the master (also acts as worker)
const int LED_PIN     = 2;
const int PWM_PIN     = 5;
const int ANALOG_PIN  = 34;

int    localPwmValue   = 0;
String masterVoltage   = "";
String worker1Voltage  = "";
String worker2Voltage  = ""; 

AsyncWebServer server(80);

// Interval for voltage updates (in ms)
const unsigned long VOLTAGE_UPDATE_INTERVAL = 2000;
unsigned long lastVoltageUpdate = 0;

// ---------------- WiFi ----------------

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP address: ");
  Serial.println(WiFi.localIP());
}

// ---------------- HTTP helpers ----------------

// Helper: call a worker URL (we ignore the response)
void callWorkerSimple(const String& url) {
  HTTPClient http;
  if (http.begin(url)) {
    int code = http.GET();
    Serial.print("Request to ");
    Serial.print(url);
    Serial.print(" -> HTTP code ");
    Serial.println(code);
    http.end();
  } else {
    Serial.print("Could not connect to ");
    Serial.println(url);
  }
}

// Helper: read a worker URL and return its response as String
String readWorker(const String& url) {
  HTTPClient http;
  String body = "";
  if (http.begin(url)) {
    int code = http.GET();
    if (code > 0) {
      body = http.getString();
    }
    http.end();
  } else {
    Serial.print("Could not connect to ");
    Serial.println(url);
  }
  return body;
}

// ---------------- Local helpers (master as worker) ----------------

void setLocalLed(int state) {
  digitalWrite(LED_PIN, state ? HIGH : LOW);
}

void setLocalPwm(int value) {
  if (value < 0) value = 0;
  if (value > 255) value = 255;
  localPwmValue = value;
  ledcWrite(PWM_PIN, localPwmValue);
}

float readLocalVoltage() {
  int raw = analogRead(ANALOG_PIN);
  float voltage = 3.3f * raw / 4096.0f;
  return voltage;
}

// ---------------- Voltage update (periodic) ----------------

void readAllVoltages() {
  masterVoltage  = String(readLocalVoltage(), 2);

  String worker1Url = String("http://") + ESP2_IP + "/voltage";
  worker1Voltage = readWorker(worker1Url);
  worker1Voltage.trim();

  String worker2Url = String("http://") + ESP3_IP + "/voltage";
  worker2Voltage = readWorker(worker2Url);
  worker2Voltage.trim();

  Serial.print("Voltages updated: master=");
  Serial.print(masterVoltage);
  Serial.print(" V, worker1=");
  Serial.print(worker1Voltage);
  Serial.print(" V, worker2=");
  Serial.print(worker2Voltage);
  Serial.println(" V");
}

// ---------------- HTML page ----------------

String buildMainPage() {
  String html;
  html += "<!DOCTYPE html><html><head><meta charset='UTF-8'>";
  html += "<title>ESP32 Master Control</title></head><body>";
  html += "<h1>ESP32 Master Control</h1>";
  html += "<p><a href='/'>Refresh page</a></p>";

  // Device 1: Master itself
  html += "<h2>Device 1 (Master ESP32)</h2>";
  html += "<p><a href='/esp1/led?state=1'>Turn LED ON</a><br>";
  html += "<a href='/esp1/led?state=0'>Turn LED OFF</a></p>";
  html += "<form action='/esp1/pwm' method='get'>";
  html += "PWM value (0-255): <input type='text' name='value'>";
  html += "<input type='submit' value='Set PWM'>";
  html += "</form>";
  html += "<p>Voltage [V]: ";
  html += masterVoltage;
  html += "</p><hr>";

  // Device 2: Worker 1
  html += "<h2>Device 2 (Worker ESP32 #2)</h2>";
  html += "<p><a href='/esp2/led?state=1'>Turn LED ON</a><br>";
  html += "<a href='/esp2/led?state=0'>Turn LED OFF</a></p>";
  html += "<form action='/esp2/pwm' method='get'>";
  html += "PWM value (0-255): <input type='text' name='value'>";
  html += "<input type='submit' value='Set PWM'>";
  html += "</form>";
  html += "<p>Voltage [V]: "; 
  html += worker1Voltage;
  html += "</p><hr>";

  // Device 3: Worker 2
  html += "<h2>Device 3 (Worker ESP32 #3)</h2>";
  html += "<p><a href='/esp3/led?state=1'>Turn LED ON</a><br>";
  html += "<a href='/esp3/led?state=0'>Turn LED OFF</a></p>";
  html += "<form action='/esp3/pwm' method='get'>";
  html += "PWM value (0-255): <input type='text' name='value'>";
  html += "<input type='submit' value='Set PWM'>";
  html += "</form>";
  html += "<p>Voltage [V]: "; 
  html += worker2Voltage;
  html += "</p><hr>";

  html += "</body></html>";
  return html;
}

// ---------------- Setup & routes ----------------

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);   // 5 kHz, 8-bit (0..255)
  ledcWrite(PWM_PIN, 0);

  connectWiFi();

  // initial voltage read to not produce an empty first page
  readAllVoltages();
  lastVoltageUpdate = millis();

  // Main page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send(200, "text/html", buildMainPage());
  });

  // ----------- Device 1 (Master itself) routes -----------

  server.on("/esp1/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    int state = request->getParam("state")->value().toInt();
    setLocalLed(state);
    request->redirect("/");
  });

  server.on("/esp1/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    int value = request->getParam("value")->value().toInt();
    setLocalPwm(value);
    request->redirect("/");
  });

  // ----------- Device 2 (Worker ESP2) routes -----------

  server.on("/esp2/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    String state = request->getParam("state")->value();
    String url = String("http://") + ESP2_IP + "/led?state=" + state;
    callWorkerSimple(url);
    request->redirect("/");
  });

  server.on("/esp2/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    String value = request->getParam("value")->value();
    String url = String("http://") + ESP2_IP + "/pwm?value=" + value;
    callWorkerSimple(url);
    request->redirect("/");
  });

  // ----------- Device 3 (Worker ESP3) routes -----------

  server.on("/esp3/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    String state = request->getParam("state")->value();
    String url = String("http://") + ESP3_IP + "/led?state=" + state;
    callWorkerSimple(url);
    request->redirect("/");
  });

  server.on("/esp3/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    String value = request->getParam("value")->value();
    String url = String("http://") + ESP3_IP + "/pwm?value=" + value;
    callWorkerSimple(url);
    request->redirect("/");
  });

  server.begin();
  Serial.println("Master server ready.");
}

// ---------------- Loop ----------------

void loop() {
  unsigned long now = millis();
  if (now - lastVoltageUpdate >= VOLTAGE_UPDATE_INTERVAL) {
    lastVoltageUpdate = now;
    readAllVoltages();
  }

  delay(1);
}
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

IPAddress ip(192,168,178,113);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

const int LED_PIN     = 2;
const int PWM_PIN     = 5;
const int ANALOG_PIN  = 34;

int pwmValue = 0;

AsyncWebServer server(80);

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP address: ");
  Serial.println(WiFi.localIP());
}

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(ANALOG_PIN, INPUT);

  // Simple PWM setup
  ledcAttach(PWM_PIN, 5000, 8);  // 5 kHz, 8-bit resolution
  ledcWrite(PWM_PIN, 0);

  connectWiFi();

  // Optional simple root page (for debugging)
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
    String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>ESP32 Worker</title></head><body>";
    html += "<h1>ESP32 Worker</h1>";
    html += "<p>This device is controlled by a master ESP32.</p>";
    html += "<p>Endpoints:</p>";
    html += "<ul>";
    html += "<li>/led?state=0|1</li>";
    html += "<li>/pwm?value=0..255</li>";
    html += "<li>/voltage</li>";
    html += "</ul>";
    html += "</body></html>";
    request->send(200, "text/html", html);
  });

  // /led?state=0|1
  server.on("/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (request->hasParam("state")) {
      int state = request->getParam("state")->value().toInt();
      digitalWrite(LED_PIN, state ? HIGH : LOW);
      String msg = "LED state set to ";
      msg += (state ? "1" : "0");
      request->send(200, "text/plain", msg);
    } else {
      request->send(400, "text/plain", "Missing state parameter");
    }
  });

  // /pwm?value=0..255
  server.on("/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (request->hasParam("value")) {
      pwmValue = request->getParam("value")->value().toInt();
      if (pwmValue < 0) pwmValue = 0;
      if (pwmValue > 255) pwmValue = 255;
      ledcWrite(PWM_PIN, pwmValue);
      String msg = "PWM value set to ";
      msg += String(pwmValue);
      request->send(200, "text/plain", msg);
    } else {
      request->send(400, "text/plain", "Missing value parameter");
    }
  });

  // /voltage -> return voltage as plain text, e.g. "1.23"
  server.on("/voltage", HTTP_GET, [](AsyncWebServerRequest* request) {
    int raw = analogRead(ANALOG_PIN);
    float voltage = 3.3f * raw / 4096.0f;
    String msg = String(voltage, 2);  // e.g. "1.23"
    request->send(200, "text/plain", msg);
  });

  server.begin();
  Serial.println("Worker server ready.");
}

void loop() {}

Hier die Ausgabe:

Async WebServer - Browserausgabe von three_esp32_asyncwebserver_only_master.ino
Browserausgabe von three_esp32_asyncwebserver_only_master.ino

Wie zuvor erwähnt: Von der Optik und vom Komfort her ist das ein Rückschritt, aber meine Intention war es, das Prinzip darzustellen.

Option 2a: Async WebServer und ESP-NOW Serial („STA-only“)

Auch in dieser Konstellation ist der Master/Worker 1 ein Server in Bezug auf das Gerät, das die Website im Browser darstellt. Die ESP-NOW-Kommunikation zwischen Master/Worker 1 und Worker 2 bzw. 3 erfolgt im Netzwerk des Routers. Worker 2 und 3 müssen auch wieder IP-Adressen zugeteilt bekommen, jedoch ist für die ESP-NOW-Kommunikation die MAC-Adresse maßgeblich. Wie ihr die MAC-Adresse ermittelt, habe ich hier beschrieben.

Drei ESP32, Kommunikation per Async WebServer und ESP-NOW Serial, „STA-only“

Sketch für den Master/Worker 1

Auf die Details von ESP-NOW Serial werde ich nicht eingehen. Das könnt ihr bei Bedarf hier nachlesen. Im Prinzip ist der Sketch genauso aufgebaut wie der vorherige Master-Sketch. Lediglich die Kommunikation zwischen Master/Worker 1 und Worker 2 bzw. 3 ist auf ESP-NOW Serial „übersetzt“. Die Daten an die Worker bzw. von den Workern werden als Strukturen übergeben. 

Und damit die Ausgabe nicht zu langweilig wird, stellen wir hier alles in Tabellenform dar.  

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"


#define ESPNOW_WIFI_CHANNEL 1        // TODO: router channel
#define VOLTAGE_UPDATE_PERIOD 2000   // local voltage update every 2s

// ---------- Pins ----------
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

// ---------- WiFi STA config ----------
constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

// ---------- Worker MAC addresses ----------
const MacAddress worker2_mac({0x94, 0x3C, 0xC6, 0x34, 0xCF, 0xA4});  // TODO
const MacAddress worker3_mac({0xC8, 0xC9, 0xA3, 0xCA, 0x22, 0x70});  // TODO

// ---------- ESP-NOW Serial ----------
ESP_NOW_Serial_Class NowSerial2(worker2_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);
ESP_NOW_Serial_Class NowSerial3(worker3_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);

// ---------- Packet structures ----------
struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

// ---------- Node data for UI ----------
struct NodeData {
  bool   ledOn;
  int    pwm;
  float  voltage;
};

// index: 0=master, 1=worker2, 2=worker3
NodeData nodes[3];

unsigned long lastLocalVoltageMillis = 0;

AsyncWebServer server(80);


// ========== Helpers ==========

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(400);
  }
  Serial.println();
  Serial.println(WiFi.localIP());
}

float readLocalVoltage() {
  int raw = analogRead(ANALOG_PIN);
  return 3.3f * raw / 4096.0f;
}

void processWorkerSerial(ESP_NOW_Serial_Class &serial, NodeData &data) {
  while (serial.available() >= (int)sizeof(WorkerStatusPacket)) {
    WorkerStatusPacket pkt;
    serial.readBytes((uint8_t*)&pkt, sizeof(pkt));
    data.voltage = pkt.voltage;
    data.pwm     = pkt.pwm;
    data.ledOn   = (pkt.ledOn != 0);
  }
}

void sendCommandToWorker(ESP_NOW_Serial_Class& serial, NodeData& node) {
  WorkerCommandPacket cmd;
  cmd.pwm = node.pwm;
  cmd.ledOn = node.ledOn ? 1 : 0;
  serial.write((uint8_t*)&cmd, sizeof(cmd));
}


// ========== Simple HTML page (NO auto-refresh) ==========

String buildMainPage() {
  String html;
  html += "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>ESP32 Master Control</title></head><body>";

  html += "<h1>ESP32 Master Control (ESP-NOW Serial)</h1>";
  html += "<p><a href='/'>Refresh page</a></p>";

  html += "<table border='1' cellpadding='4' cellspacing='0'>";
  html += "<tr><th>Device</th><th>LED</th><th>PWM</th><th>Voltage [V]</th></tr>";

  // -------- Master (index 0) --------
  html += "<tr><td>ESP1 (Master)</td><td>";
  html += (nodes[0].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp1/led?state=1'>ON</a> | ";
  html += "<a href='/esp1/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[0].pwm);
  html += "<br><form action='/esp1/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'><input type='submit' value='Set'></form>";
  html += "</td><td>";
  html += String(nodes[0].voltage, 2);
  html += "</td></tr>";

  // -------- Worker 2 (index 1) --------
  html += "<tr><td>ESP2 (Worker)</td><td>";
  html += (nodes[1].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp2/led?state=1'>ON</a> | ";
  html += "<a href='/esp2/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[1].pwm);
  html += "<br><form action='/esp2/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'><input type='submit' value='Set'></form>";
  html += "</td><td>";
  html += String(nodes[1].voltage, 2);
  html += "</td></tr>";

  // -------- Worker 3 (index 2) --------
  html += "<tr><td>ESP3 (Worker)</td><td>";
  html += (nodes[2].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp3/led?state=1'>ON</a> | ";
  html += "<a href='/esp3/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[2].pwm);
  html += "<br><form action='/esp3/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'><input type='submit' value='Set'></form>";
  html += "</td><td>";
  html += String(nodes[2].voltage, 2);
  html += "</td></tr>";

  html += "</table></body></html>";
  return html;
}


// ========== Setup ==========

void setupWiFiAndEspNow() {
  connectWiFi();
  NowSerial2.begin(115200);
  NowSerial3.begin(115200);
}

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  for (int i = 0; i < 3; i++) {
    nodes[i].ledOn   = false;
    nodes[i].pwm     = 0;
    nodes[i].voltage = 0.0f;
  }

  setupWiFiAndEspNow();

  // -------- Web routes --------
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send(200, "text/html", buildMainPage());
  });

  // Master LED
  server.on("/esp1/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    int s = request->getParam("state")->value().toInt();
    nodes[0].ledOn = (s != 0);
    digitalWrite(LED_PIN, nodes[0].ledOn ? HIGH : LOW);
    request->redirect("/");
  });

  // Master PWM
  server.on("/esp1/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    int val = request->getParam("value")->value().toInt();
    if (val < 0) val = 0;
    if (val > 255) val = 255;
    nodes[0].pwm = val;
    ledcWrite(PWM_PIN, nodes[0].pwm);
    request->redirect("/");
  });

  // Worker 2 LED
  server.on("/esp2/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    int s = request->getParam("state")->value().toInt();
    nodes[1].ledOn = (s != 0);
    sendCommandToWorker(NowSerial2, nodes[1]);
    request->redirect("/");
  });

  // Worker 2 PWM
  server.on("/esp2/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    int val = request->getParam("value")->value().toInt();
    if (val < 0) val = 0;
    if (val > 255) val = 255;
    nodes[1].pwm = val;
    sendCommandToWorker(NowSerial2, nodes[1]);
    request->redirect("/");
  });

  // Worker 3 LED
  server.on("/esp3/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    int s = request->getParam("state")->value().toInt();
    nodes[2].ledOn = (s != 0);
    sendCommandToWorker(NowSerial3, nodes[2]);
    request->redirect("/");
  });

  // Worker 3 PWM
  server.on("/esp3/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    int val = request->getParam("value")->value().toInt();
    if (val < 0) val = 0;
    if (val > 255) val = 255;
    nodes[2].pwm = val;
    sendCommandToWorker(NowSerial3, nodes[2]);
    request->redirect("/");
  });

  server.begin();
  Serial.println("Master ready.");
}


// ========== Loop ==========

void loop() {
  unsigned long now = millis();

  // Update master's own voltage
  if (now - lastLocalVoltageMillis > VOLTAGE_UPDATE_PERIOD) {
    lastLocalVoltageMillis = now;
    nodes[0].voltage = readLocalVoltage();
  }

  // Process worker packets
  processWorkerSerial(NowSerial2, nodes[1]);
  processWorkerSerial(NowSerial3, nodes[2]);

  delay(1);
}
#include <WiFi.h>
#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"

#define ESPNOW_WIFI_CHANNEL 1        // same as master / router
#define STATUS_SEND_PERIOD 2000      // [ms]

// same pins as on master
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

IPAddress ip(192,168,178,113);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

// ---------- WiFi STA config ----------
constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

// ---------- MAC of master (STA interface) ----------
const MacAddress master_mac({0xC8, 0xC9, 0xA3, 0xC6, 0xFE, 0x54});  // TODO

ESP_NOW_Serial_Class NowSerial(master_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);

// data packets
struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

// local state
uint8_t currentPwm   = 0;
bool    currentLedOn = false;

unsigned long lastStatusSent = 0;

// ---------- helpers ----------
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP address: ");
  Serial.println(WiFi.localIP());
}

float readVoltage() {
  int raw = analogRead(ANALOG_PIN);
  float voltage = 3.3f * raw / 4096.0f;
  return voltage;
}

void applyCommand(const WorkerCommandPacket& cmd) {
  currentPwm   = cmd.pwm;
  currentLedOn = (cmd.ledOn != 0);

  ledcWrite(PWM_PIN, currentPwm);
  digitalWrite(LED_PIN, currentLedOn ? HIGH : LOW);
}

void sendStatus() {
  WorkerStatusPacket pkt;
  pkt.voltage = readVoltage();
  pkt.pwm     = currentPwm;
  pkt.ledOn   = currentLedOn ? 1 : 0;

  int ok = NowSerial.write((uint8_t*)&pkt, sizeof(pkt));
  if (!ok) {
    Serial.println("Status send failed");
  }
}

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  connectWiFi();

  // ESP-NOW Serial on STA interface
  NowSerial.begin(115200);

  Serial.println("Worker ready.");
}

// ---------- loop() ----------
void loop() {
  unsigned long now = millis();

  // periodic status to master
  if (now - lastStatusSent > STATUS_SEND_PERIOD) {
    lastStatusSent = now;
    sendStatus();
  }

  // incoming commands
  while (NowSerial.available() >= (int)sizeof(WorkerCommandPacket)) {
    WorkerCommandPacket cmd;
    NowSerial.readBytes((uint8_t*)&cmd, sizeof(cmd));
    applyCommand(cmd);
  }

  delay(1);
}

Und so sieht die Ausgabe aus:

Async WebServer - Browserausgabe von three_esp32_espnow_sta_master.ino
Browserausgabe von three_esp32_espnow_sta_master.ino

Option 2b: Async WebServer und ESP-NOW Serial (STA/AP)

Bei dieser Konstellation müssen wir wie gewohnt einen Async WebServer auf dem Master-ESP32 einrichten. Zusätzlich stellt der Master-ESP32 ein Netzwerk bereit, in das sich Worker 2 und Worker 3 einklinken. Für dieses Netzwerk vergebt ihr einen eigenen Namen und ein eigenes Passwort.

Ein wichtiger Punkt ist, dass für die ESP-NOW Serial Kommunikation die SoftAP-MAC-Adresse des Master-ESP32 verwendet wird. Wie ihr sie ermittelt, habe ich hier beschrieben. Normalerweise müsst ihr aber lediglich eine 1 zu der letzten Zahl der MAC-Adresse addieren.

Drei ESP32, Kommunikation per Async WebServer und ESP-NOW Serial, „AP_STA-Mode“

Das Netzwerk wird in der Funktion startAPForWorkers() aufgesetzt. Das sollte einigermaßen selbsterklärend sein. Was euch vielleicht auffällt, ist, dass wir in dieser Version den ESPNOW_WIFI_CHANNEL für alle Beteiligten vorgeben. Das ist notwendig, weil wir zwei Netzwerke haben und sich die Wi-Fi-Kanäle der beiden bei automatischer Auswahl unterscheiden könnten. 

Auf der Worker-Seite ändert sich nicht viel, außer, dass die SSID und das Passwort des Netzwerks des Master-ESP32 verwendet werden. 

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"
#include "esp_wifi.h"

#define ESPNOW_WIFI_CHANNEL 1        // All ESP32 and the master's AP use this channel
#define VOLTAGE_UPDATE_PERIOD 2000   // [ms] local voltage update period

// ---------- Pins ----------
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

// ---------- WiFi STA (home network) ----------
IPAddress ip(192,168,178,112);     //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);   //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

// ---------- WiFi AP (for the workers) ----------
const char* AP_SSID = "ESP32-Master";        // TODO: choose AP SSID for workers
const char* AP_PASS = "12345678";            // TODO: choose AP password (>= 8 chars)

// ---------- Worker MAC addresses (STA interface of ESP2 and ESP3) ----------
const MacAddress worker2_mac({0x94, 0x3C, 0xC6, 0x34, 0xCF, 0xA4});  // TODO
const MacAddress worker3_mac({0xC8, 0xC9, 0xA3, 0xCA, 0x22, 0x70});  // TODO

// ---------- ESP-NOW Serial on MASTER: use AP interface ----------
ESP_NOW_Serial_Class NowSerial2(worker2_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_AP);
ESP_NOW_Serial_Class NowSerial3(worker3_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_AP);

// ---------- Packet structures ----------
struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

// ---------- Node data for UI ----------
struct NodeData {
  bool   ledOn;
  int    pwm;
  float  voltage;
};

// index: 0 = master, 1 = worker2, 2 = worker3
NodeData nodes[3];

unsigned long lastLocalVoltageMillis = 0;

AsyncWebServer server(80);


// ================== Helper functions ==================

void connectWiFiSTA() {
  // Master connects as STA to your home router
  WiFi.mode(WIFI_AP_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to home WiFi (STA)");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(400);
  }
  Serial.println();
  Serial.print("STA IP address (use this in browser): ");
  Serial.println(WiFi.localIP());
}

void startAPForWorkers() {
  // Master starts an AP for the worker ESP32 boards
  bool ok = WiFi.softAP(AP_SSID, AP_PASS, ESPNOW_WIFI_CHANNEL);
  if (!ok) {
    Serial.println("Error starting AP. Restarting...");
    delay(2000);
    ESP.restart();
  }
  Serial.print("AP started. SSID: ");
  Serial.println(AP_SSID);
  Serial.print("AP IP: ");
  Serial.println(WiFi.softAPIP());
}

float readLocalVoltage() {
  int raw = analogRead(ANALOG_PIN);
  return 3.3f * raw / 4096.0f;
}

void processWorkerSerial(ESP_NOW_Serial_Class &serial, NodeData &data) {
  while (serial.available() >= (int)sizeof(WorkerStatusPacket)) {
    WorkerStatusPacket pkt;
    serial.readBytes((uint8_t*)&pkt, sizeof(pkt));
    data.voltage = pkt.voltage;
    data.pwm     = pkt.pwm;
    data.ledOn   = (pkt.ledOn != 0);
  }
}

void sendCommandToWorker(ESP_NOW_Serial_Class& serial, NodeData& node) {
  WorkerCommandPacket cmd;
  cmd.pwm   = (uint8_t)node.pwm;
  cmd.ledOn = node.ledOn ? 1 : 0;
  serial.write((uint8_t*)&cmd, sizeof(cmd));
}


// ================== HTML page (no auto-refresh, no last-update) ==================

String buildMainPage() {
  String html;
  html += "<!DOCTYPE html><html><head><meta charset='UTF-8'>";
  html += "<title>ESP32 Master Control</title></head><body>";

  html += "<h1>ESP32 Master Control (ESP-NOW Serial, AP+STA)</h1>";
  html += "<p>STA IP address: ";
  html += WiFi.localIP().toString();
  html += "</p>";
  html += "<p><a href='/'>Refresh page</a></p>";

  html += "<table border='1' cellpadding='4' cellspacing='0'>";
  html += "<tr><th>Device</th><th>LED</th><th>PWM</th><th>Voltage [V]</th></tr>";

  // ----- ESP1 (Master / Worker #1) -----
  html += "<tr><td>ESP1 (Master)</td><td>";
  html += (nodes[0].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp1/led?state=1'>ON</a> | ";
  html += "<a href='/esp1/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[0].pwm);
  html += "<br><form action='/esp1/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'>";
  html += "<input type='submit' value='Set'>";
  html += "</form>";
  html += "</td><td>";
  html += String(nodes[0].voltage, 2);
  html += "</td></tr>";

  // ----- ESP2 (Worker) -----
  html += "<tr><td>ESP2 (Worker)</td><td>";
  html += (nodes[1].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp2/led?state=1'>ON</a> | ";
  html += "<a href='/esp2/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[1].pwm);
  html += "<br><form action='/esp2/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'>";
  html += "<input type='submit' value='Set'>";
  html += "</form>";
  html += "</td><td>";
  html += String(nodes[1].voltage, 2);
  html += "</td></tr>";

  // ----- ESP3 (Worker) -----
  html += "<tr><td>ESP3 (Worker)</td><td>";
  html += (nodes[2].ledOn ? "ON" : "OFF");
  html += " (";
  html += "<a href='/esp3/led?state=1'>ON</a> | ";
  html += "<a href='/esp3/led?state=0'>OFF</a>)";
  html += "</td><td>";
  html += String(nodes[2].pwm);
  html += "<br><form action='/esp3/pwm' method='get'>Set PWM: ";
  html += "<input type='text' name='value' size='4'>";
  html += "<input type='submit' value='Set'>";
  html += "</form>";
  html += "</td><td>";
  html += String(nodes[2].voltage, 2);
  html += "</td></tr>";

  html += "</table></body></html>";
  return html;
}


// ================== Setup ==================

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  // init node data
  for (int i = 0; i < 3; i++) {
    nodes[i].ledOn   = false;
    nodes[i].pwm     = 0;
    nodes[i].voltage = 0.0f;
  }

  // combined STA (home WiFi) + AP (for workers)
  connectWiFiSTA();
  startAPForWorkers();

  // ESP-NOW Serial uses AP interface on the master
  NowSerial2.begin(115200);
  NowSerial3.begin(115200);

  // ---------- Web routes ----------

  // main page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
    request->send(200, "text/html", buildMainPage());
  });

  // ---- Master LED ----
  server.on("/esp1/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    int s = request->getParam("state")->value().toInt();
    nodes[0].ledOn = (s != 0);
    digitalWrite(LED_PIN, nodes[0].ledOn ? HIGH : LOW);
    request->redirect("/");
  });

  // ---- Master PWM ----
  server.on("/esp1/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    int val = request->getParam("value")->value().toInt();
    if (val < 0)   val = 0;
    if (val > 255) val = 255;
    nodes[0].pwm = val;
    ledcWrite(PWM_PIN, nodes[0].pwm);
    request->redirect("/");
  });

  // ---- Worker 2 LED ----
  server.on("/esp2/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    int s = request->getParam("state")->value().toInt();
    nodes[1].ledOn = (s != 0);
    sendCommandToWorker(NowSerial2, nodes[1]);
    request->redirect("/");
  });

  // ---- Worker 2 PWM ----
  server.on("/esp2/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    int val = request->getParam("value")->value().toInt();
    if (val < 0)   val = 0;
    if (val > 255) val = 255;
    nodes[1].pwm = val;
    sendCommandToWorker(NowSerial2, nodes[1]);
    request->redirect("/");
  });

  // ---- Worker 3 LED ----
  server.on("/esp3/led", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("state")) {
      request->send(400, "text/plain", "Missing state parameter");
      return;
    }
    int s = request->getParam("state")->value().toInt();
    nodes[2].ledOn = (s != 0);
    sendCommandToWorker(NowSerial3, nodes[2]);
    request->redirect("/");
  });

  // ---- Worker 3 PWM ----
  server.on("/esp3/pwm", HTTP_GET, [](AsyncWebServerRequest* request) {
    if (!request->hasParam("value")) {
      request->send(400, "text/plain", "Missing value parameter");
      return;
    }
    int val = request->getParam("value")->value().toInt();
    if (val < 0)   val = 0;
    if (val > 255) val = 255;
    nodes[2].pwm = val;
    sendCommandToWorker(NowSerial3, nodes[2]);
    request->redirect("/");
  });

  server.begin();
  Serial.println("Master (AP+STA) ready.");

  Serial.print("Soft AP MAC address: ");
  Serial.println(WiFi.softAPmacAddress());   // Master-AP-MAC für master_ap_mac
}


// ================== Loop ==================

void loop() {
  unsigned long now = millis();

  // Update master's own voltage every 2 s
  if (now - lastLocalVoltageMillis > VOLTAGE_UPDATE_PERIOD) {
    lastLocalVoltageMillis = now;
    nodes[0].voltage = readLocalVoltage();
  }

  // Process worker packets
  processWorkerSerial(NowSerial2, nodes[1]);
  processWorkerSerial(NowSerial3, nodes[2]);

  delay(1);
}
#include <WiFi.h>
#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"
#include "esp_wifi.h"

#define ESPNOW_WIFI_CHANNEL 1
#define STATUS_SEND_PERIOD 2000  // [ms]

// Same pins as on master
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

// -------- WiFi STA: connect to master's AP --------
const char* AP_SSID = "ESP32-Master";   // must match master's AP_SSID
const char* AP_PASS = "12345678";       // must match master's AP_PASS

const MacAddress master_ap_mac({0xC8, 0xC9, 0xA3, 0xC6, 0xFE, 0x55});  // TODO: adjust

// ESP-NOW Serial on worker: use STA interface
ESP_NOW_Serial_Class NowSerial(master_ap_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);

// ---------- Data packets ----------
struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

uint8_t currentPwm   = 0;
bool    currentLedOn = false;

unsigned long lastStatusSent = 0;


// ================== Helper functions ==================

void connectToMasterAP() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(AP_SSID, AP_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to master AP");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(400);
  }
  Serial.println();
  Serial.print("Worker STA IP (not really needed for ESP-NOW): ");
  Serial.println(WiFi.localIP());
}

float readVoltage() {
  int raw = analogRead(ANALOG_PIN);
  return 3.3f * raw / 4096.0f;
}

void applyCommand(const WorkerCommandPacket& cmd) {
  currentPwm   = cmd.pwm;
  currentLedOn = (cmd.ledOn != 0);

  ledcWrite(PWM_PIN, currentPwm);
  digitalWrite(LED_PIN, currentLedOn ? HIGH : LOW);
}

void sendStatus() {
  WorkerStatusPacket pkt;
  pkt.voltage = readVoltage();
  pkt.pwm     = currentPwm;
  pkt.ledOn   = currentLedOn ? 1 : 0;

  int ok = NowSerial.write((uint8_t*)&pkt, sizeof(pkt));
  if (!ok) {
    Serial.println("Status send failed");
  }
}


// ================== Setup ==================

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  connectToMasterAP();

  NowSerial.begin(115200);

  Serial.println("Worker ready (STA → master AP).");
}


// ================== Loop ==================

void loop() {
  unsigned long now = millis();

  // periodically send status to master
  if (now - lastStatusSent > STATUS_SEND_PERIOD) {
    lastStatusSent = now;
    sendStatus();
  }

  // handle incoming commands
  while (NowSerial.available() >= (int)sizeof(WorkerCommandPacket)) {
    WorkerCommandPacket cmd;
    NowSerial.readBytes((uint8_t*)&cmd, sizeof(cmd));
    applyCommand(cmd);
  }

  delay(1);
}

Komplettversion ESP-NOW Serial („STA-only“)

Ich möchte dann doch noch einmal eine Komplettversion, also mit Slidern und Switches für die „STA-only“-Version mit ESP-NOW Serial zur Verfügung stellen.  

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"

// ================== CONFIG ==================

#define ESPNOW_WIFI_CHANNEL     1
#define VOLTAGE_UPDATE_PERIOD  2000

// ---------- Pins ----------
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

// ---------- Network ----------
IPAddress ip(192,168,178,112);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO

// ---------- Worker MACs ----------
const MacAddress worker2_mac({0x94,0x3C,0xC6,0x34,0xCF,0xA4});  //TODO
const MacAddress worker3_mac({0xC8,0xC9,0xA3,0xCA,0x22,0x70});  //TODO

// ---------- ESP-NOW Serial ----------
ESP_NOW_Serial_Class NowSerial2(worker2_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);
ESP_NOW_Serial_Class NowSerial3(worker3_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);

// ================== DATA ==================

struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

struct NodeData {
  bool  ledOn;
  int   pwm;
  float voltage;
};

// index: 0 = master, 1 = worker2, 2 = worker3
NodeData nodes[3];

unsigned long lastLocalVoltageMillis = 0;

AsyncWebServer server(80);

// ================== HELPERS ==================

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(400);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());
}

float readLocalVoltage() {
  int raw = analogRead(ANALOG_PIN);
  return 3.3f * raw / 4096.0f;
}

void processWorkerSerial(ESP_NOW_Serial_Class &serial, NodeData &data) {
  while (serial.available() >= (int)sizeof(WorkerStatusPacket)) {
    WorkerStatusPacket pkt;
    serial.readBytes((uint8_t*)&pkt, sizeof(pkt));
    data.voltage = pkt.voltage;
    data.pwm     = pkt.pwm;
    data.ledOn   = pkt.ledOn != 0;
  }
}

void sendCommandToWorker(ESP_NOW_Serial_Class &serial, NodeData &node) {
  WorkerCommandPacket cmd;
  cmd.pwm   = node.pwm;
  cmd.ledOn = node.ledOn ? 1 : 0;
  serial.write((uint8_t*)&cmd, sizeof(cmd));
}

// ================== HTML UI ==================

String buildMainPage() {
  String html;
  html += F(R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 ESP-NOW Control</title>
<style>
body{font-family:Arial,Helvetica,sans-serif;background:#f0f2f5;margin:0}
.container{max-width:900px;margin:30px auto;background:#fff;
padding:20px;border-radius:14px;box-shadow:0 6px 18px rgba(0,0,0,.1)}
h1{text-align:center;margin-bottom:10px}
table{width:100%;border-collapse:collapse}
th,td{padding:10px;text-align:center;border-bottom:1px solid #ddd}

.switch{position:relative;display:inline-block;width:50px;height:26px}
.switch input{display:none}
.slider{position:absolute;cursor:pointer;inset:0;background:#ccc;border-radius:34px}
.slider:before{content:"";position:absolute;height:20px;width:20px;left:3px;bottom:3px;
background:white;border-radius:50%;transition:.2s}
input:checked+.slider{background:#4CAF50}
input:checked+.slider:before{transform:translateX(24px)}

.pwm-box{display:inline-flex;align-items:center;gap:8px;width:260px}
input[type=range]{width:180px}

.value{
  font-variant-numeric: tabular-nums;
  display:inline-block;
  width:3ch;
  text-align:right;
}
</style>
</head>
<body>
<div class="container">
<h1>ESP32 ESP-NOW Control</h1>

<table>
<tr><th>Device</th><th>LED</th><th>PWM</th><th>Voltage [V]</th></tr>

<script>
async function refreshState(){
  const r = await fetch('/api/state',{cache:'no-store'});
  const s = await r.json();
  [1,2,3].forEach(d=>{
    const n=s['dev'+d];
    document.getElementById('led'+d).checked=n.ledOn;
    document.getElementById('pwm'+d).value=n.pwm;
    document.getElementById('pwmt'+d).textContent=n.pwm;
    document.getElementById('v'+d).textContent=n.voltage.toFixed(2);
  });
}
async function setLed(dev,val){
  await fetch(`/api/set?dev=${dev}&led=${val}`);
}
async function setPwm(dev,val){
  await fetch(`/api/set?dev=${dev}&pwm=${val}`);
}
setInterval(refreshState,5000);
window.onload=refreshState;
</script>
)rawliteral");

  const char* names[]={"ESP1 (Master)","ESP2 (Worker)","ESP3 (Worker)"};

  for(int i=1;i<=3;i++){
    html += "<tr><td>"+String(names[i-1])+"</td>";

    html += "<td><label class='switch'><input type='checkbox' id='led"+String(i)+"' ";
    html += "onchange='setLed("+String(i)+",this.checked?1:0)'>";
    html += "<span class='slider'></span></label></td>";

    html += "<td><div class='pwm-box'>";
    html += "<input type='range' min='0' max='255' id='pwm"+String(i)+"' ";
    html += "oninput='pwmt"+String(i)+".textContent=this.value' ";
    html += "onchange='setPwm("+String(i)+",this.value)'>";
    html += "<span class='value' id='pwmt"+String(i)+"'>0</span>";
    html += "</div></td>";

    html += "<td><span class='value' id='v"+String(i)+"'>0.00</span></td></tr>";
  }

  html += "</table></div></body></html>";
  return html;
}

// ================== JSON API ==================

String jsonState() {
  String j="{";
  for(int i=0;i<3;i++){
    j+="\"dev"+String(i+1)+"\":{";
    j+="\"ledOn\":"+String(nodes[i].ledOn?1:0)+",";
    j+="\"pwm\":"+String(nodes[i].pwm)+",";
    j+="\"voltage\":"+String(nodes[i].voltage,3)+"}";
    if(i<2) j+=",";
  }
  j+="}";
  return j;
}

// ================== SETUP ==================

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

  pinMode(LED_PIN, OUTPUT);
  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  for(int i=0;i<3;i++){
    nodes[i]={false,0,0.0f};
  }

  connectWiFi();
  NowSerial2.begin(115200);
  NowSerial3.begin(115200);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest* r){
    r->send(200,"text/html",buildMainPage());
  });

  server.on("/api/state", HTTP_GET, [](AsyncWebServerRequest* r){
    r->send(200,"application/json",jsonState());
  });

  server.on("/api/set", HTTP_GET, [](AsyncWebServerRequest* r){
    int dev=r->getParam("dev")->value().toInt();
    bool hasLed=r->hasParam("led");
    bool hasPwm=r->hasParam("pwm");

    if(dev==1){
      if(hasLed){ nodes[0].ledOn=r->getParam("led")->value().toInt(); digitalWrite(LED_PIN,nodes[0].ledOn); }
      if(hasPwm){ nodes[0].pwm=r->getParam("pwm")->value().toInt(); ledcWrite(PWM_PIN,nodes[0].pwm); }
    }
    if(dev==2){
      if(hasLed) nodes[1].ledOn=r->getParam("led")->value().toInt();
      if(hasPwm) nodes[1].pwm=r->getParam("pwm")->value().toInt();
      sendCommandToWorker(NowSerial2,nodes[1]);
    }
    if(dev==3){
      if(hasLed) nodes[2].ledOn=r->getParam("led")->value().toInt();
      if(hasPwm) nodes[2].pwm=r->getParam("pwm")->value().toInt();
      sendCommandToWorker(NowSerial3,nodes[2]);
    }
    r->send(200,"text/plain","OK");
  });

  server.begin();
  Serial.println("Master ready.");
}

// ================== LOOP ==================

void loop() {
  unsigned long now=millis();

  if(now-lastLocalVoltageMillis>VOLTAGE_UPDATE_PERIOD){
    lastLocalVoltageMillis=now;
    nodes[0].voltage=readLocalVoltage();
  }

  processWorkerSerial(NowSerial2,nodes[1]);
  processWorkerSerial(NowSerial3,nodes[2]);

  delay(1);
}
#include <WiFi.h>
#include "ESP32_NOW_Serial.h"
#include "MacAddress.h"

#define ESPNOW_WIFI_CHANNEL 1        // same as master / router
#define STATUS_SEND_PERIOD 2000      // [ms]

// same pins as on master
const int LED_PIN    = 2;
const int PWM_PIN    = 5;
const int ANALOG_PIN = 34;

IPAddress ip(192,168,178,113);  //TODO
IPAddress gateway(192,168,178,1);  //TODO
IPAddress subnet(255,255,255,0);  //TODO

// ---------- WiFi STA config ----------
constexpr char WIFI_SSID[] = "Your SSID";  //TODO
constexpr char WIFI_PASS[] = "Your password";  //TODO
// ---------- MAC of master (STA interface) ----------

const MacAddress master_mac({0xC8, 0xC9, 0xA3, 0xC6, 0xFE, 0x54});  // TODO

ESP_NOW_Serial_Class NowSerial(master_mac, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA);

// data packets
struct WorkerStatusPacket {
  float   voltage;
  uint8_t pwm;
  uint8_t ledOn;
};

struct WorkerCommandPacket {
  uint8_t pwm;
  uint8_t ledOn;
};

// local state
uint8_t currentPwm   = 0;
bool    currentLedOn = false;

unsigned long lastStatusSent = 0;

// ---------- helpers ----------
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.config(ip, gateway, subnet);
  WiFi.begin(WIFI_SSID, WIFI_PASS, ESPNOW_WIFI_CHANNEL);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected. IP address: ");
  Serial.println(WiFi.localIP());
}

float readVoltage() {
  int raw = analogRead(ANALOG_PIN);
  float voltage = 3.3f * raw / 4096.0f;
  return voltage;
}

void applyCommand(const WorkerCommandPacket& cmd) {
  currentPwm   = cmd.pwm;
  currentLedOn = (cmd.ledOn != 0);

  ledcWrite(PWM_PIN, currentPwm);
  digitalWrite(LED_PIN, currentLedOn ? HIGH : LOW);
}

void sendStatus() {
  WorkerStatusPacket pkt;
  pkt.voltage = readVoltage();
  pkt.pwm     = currentPwm;
  pkt.ledOn   = currentLedOn ? 1 : 0;

  int ok = NowSerial.write((uint8_t*)&pkt, sizeof(pkt));
  if (!ok) {
    Serial.println("Status send failed");
  }
}

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

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  pinMode(ANALOG_PIN, INPUT);

  ledcAttach(PWM_PIN, 5000, 8);
  ledcWrite(PWM_PIN, 0);

  connectWiFi();

  // ESP-NOW Serial on STA interface
  NowSerial.begin(115200);

  Serial.println("Worker ready.");
}

// ---------- loop() ----------
void loop() {
  unsigned long now = millis();

  // periodic status to master
  if (now - lastStatusSent > STATUS_SEND_PERIOD) {
    lastStatusSent = now;
    sendStatus();
  }

  // incoming commands
  while (NowSerial.available() >= (int)sizeof(WorkerCommandPacket)) {
    WorkerCommandPacket cmd;
    NowSerial.readBytes((uint8_t*)&cmd, sizeof(cmd));
    applyCommand(cmd);
  }

  delay(1);
}

 

Und hier noch das finale Ergebnis im Browser:

Async WebServer - Browserausgabe von three_esp32_espnow_sta_complete_master.ino
Browserausgabe von three_esp32_espnow_sta_complete_master.ino

10 thoughts on “Async WebServer mit dem ESP32

  1. Hallo Wolfgang,

    dies ist mal wieder ein Meisterwerk von dir!
    Vielen Dank dafür, ich habe sehr viel dazu lernen können!
    Wo nimmst du die Kraft, Energie und vor allem Zeit her, um so etwas zu schaffen!
    Schade, dass wir keine Nachbarn sind, wir wären ein tolles Team!
    Ich wünsche dir eine schöne, ruhige Weihnachtszeit im Kreise deiner Familie, ohne Arduino!

    Schöe Grüße
    Enno Jürgens

  2. Hallo Ewald,
    fantastisch Deine Erklärungen. So verstehe ich das auch mal endlich.
    Gruß aus Ochtrup

  3. Diese Zusammenstellung ist für mich einechtes Weihnachtsgeschenk.
    Endlich verstehe ich die Teile, die ich bisher mühsam von Beispielen übernommen habe.
    Vielen Dank.

  4. Hallo Wolle,
    ganz lieben Dank für die vielen Infos und Anregungen.
    Nach meiner Erfahrung gibt es allerdings Probleme, sobald man z.B. LEDs (WS2812 oder ähnlich) mit dem Smartphone dimmen will. Dann steigt der ESP wegen Laufzeitproblemen aus, da der Client ständig requests feuert, aber jede Ausführung bei vielen LEDs in den Millisekundenbereich(!) gehen kann. Hast du da einen Programmiertrick, z.B. indem der ESP erst auf einen request reagiert, wenn er seinen Job erledigt hat?
    Danke und beste Grüße, Hans

    1. Hallo Hans,

      ich würde da auf ein Ansatz gehen der dad Websocket protokoll nutzt.
      Da wird in Background ein Verbindung mit websocket erstellt, da braucht der Client nicht feuern.
      Ich habe das Auf dem ESP01 umgesetzt, der als Garagentoröffner im Einsatz ist.

    2. Hallo Hans,

      ich denke, hier kommt es sehr darauf an, wie das Dimmen implementiert ist. Der Slider, den ich hier verwende, löst erst einen Request aus, wenn du ihn wieder loslässt. So schnell kannst du ihn gar nicht hin- und herziehen, dass der ESP32 wg. „Requeststaus“ aussteigt, egal ob mit Smartphone oder PC. Was nicht heißt, dass es das Problem nicht geben kann. Ich möchte nur nicht, dass hier der Eindruck entsteht, Dimmen mithilfe eines Async WebServers würde generell ein Problem darstellen.

      Ansonsten kannst du natürlich den „klassischen“ Webserver verwenden:
      https://wolles-elektronikkiste.de/wlan-mit-esp8266-und-esp32
      Der ist blockierend, d.h. ein Request muss erst vollständig abgearbeitet werden, bevor der nächste an die Reihe kommt. Allerdings kann man den klassischen Webserver auch überlasten, da irgendwann der Puffer voll ist. „Abstürzen“ tut der ESP32 allerdings nur unter bestimmten Bedingungen (z.B. Heap-Crash).

      Ansonsten nutze das Websocket-Protokoll, wie von Frank vorgeschlagen, wenn es auf hohe Geschwindigkeit bzw. niedrige Latenz ankommt.

      Grüße, Wolfgang

    3. Hallo Noch mal,

      sorry für die Typos.
      Noch eine Anmerkung, wenn Du Daten im ms Takt über WLAN übertragen und auf einer Webseite darstellen willst dann ist WLAN vermutlich das falsche Medium.
      Aber per WLAN via websocket im s Takt ist kein Problem.

  5. Hallo Wolle, das ist ein hochinteressantes Thema. Ich sitze auch gerade daran und will meine alte analoge Modellbahn damit steuern. Weiter so. Echt interessant.
    Gruß, Bernhard

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert