WebSockets mit ESP32: Live-Diagramm im Browser ohne Seiten-Reload

Du hast einen ESP32-Webserver gebaut, der Temperatur anzeigt – und drückst jetzt alle paar Sekunden F5. Oder du hast einen setInterval eingebaut, der die Seite ständig neu abfragt und dabei jedes Mal eine komplette HTTP-Verbindung auf- und abbaut. Es geht deutlich eleganter: Mit WebSockets bleibt eine einzige Verbindung offen, und der ESP32 schiebt neue Werte von sich aus in den Browser. Ergebnis: Live-Diagramme, die sich mehrmals pro Sekunde aktualisieren, ohne dass irgendetwas neu lädt.

Warum Polling der falsche Weg ist

Der klassische Ansatz sieht harmlos aus: Der Browser fragt alle zwei Sekunden per fetch('/temperatur') nach. Was dabei tatsächlich passiert, ist allerdings jedes Mal ein kompletter Vorgang – TCP-Handshake, HTTP-Header hin, HTTP-Header zurück, Verbindung schließen. Für vier Byte Nutzdaten.

Verfahren Overhead pro Wert Latenz Richtung Gut für
HTTP-Polling ca. 400–800 Byte bis zum nächsten Intervall nur Abfrage seltene Werte
Server-Sent Events ca. 20 Byte sofort nur Server → Browser reine Anzeige
WebSocket ca. 6 Byte sofort in beide Richtungen Dashboards & Steuerung

Ein WebSocket beginnt als ganz normale HTTP-Anfrage mit dem Header Upgrade: websocket. Antwortet der Server mit Status 101, wird aus der HTTP-Verbindung ein dauerhafter, bidirektionaler Kanal. Ab da kosten Nachrichten praktisch nur noch ihre Nutzdaten plus einen winzigen Rahmen.

ℹ️ Wann reicht SSE? Wenn du wirklich nur Werte anzeigen willst und nie etwas zurückschickst, sind Server-Sent Events einfacher – im Browser reicht new EventSource('/events'), und Reconnect passiert automatisch. Sobald du aber auch schalten willst (Relais, Sollwerte, Kalibrierung), brauchst du den Rückkanal. Dann ist der WebSocket die bessere Wahl, weil du nicht zwei Mechanismen parallel pflegen musst.

Das brauchst du

ESP32 Entwicklungsboard (WROOM32, USB-C) – genügend RAM für mehrere gleichzeitige Clients
BMP280 Sensor (I2C) – liefert Temperatur und Luftdruck über zwei Leitungen
DHT22 (AM2302) – für die Luftfeuchtigkeit als dritte Kurve
Relais-Modul KY-019 – damit die Verbindung auch in die Gegenrichtung etwas tut
0,96 Zoll OLED SSD1306 – zeigt IP-Adresse und Anzahl verbundener Clients
DS18B20 (1 m Kabel) – optional als zweiter, räumlich getrennter Messpunkt

1Verkabelung

Bauteil Pin am Modul ESP32 Hinweis
BMP280 SDA / SCL GPIO21 / GPIO22 Adresse 0x76 oder 0x77
BMP280 VCC / GND 3V3 / GND
OLED SSD1306 SDA / SCL GPIO21 / GPIO22 gleicher I2C-Bus, Adresse 0x3C
DHT22 DATA GPIO27 10 kΩ Pull-Up nach 3V3
DS18B20 DATA GPIO4 4,7 kΩ Pull-Up nach 3V3
Relais KY-019 SIG GPIO26 VCC auf 5V (VIN)

OLED und BMP280 teilen sich denselben I2C-Bus – das ist genau der Sinn von I2C. Falls sich zwei Module in die Quere kommen, hilft der Artikel zum I2C-Bus und Adresskonflikten weiter.

2Bibliotheken installieren

Für dieses Projekt nutzen wir ESPAsyncWebServer. Der große Vorteil gegenüber dem eingebauten WebServer: Er blockiert nicht. Während ein Client eine Seite lädt, läuft dein loop() ungebremst weiter – genau das brauchst du, wenn nebenher Sensoren abgetastet werden.

  • ESPAsyncWebServer (ESP32Async oder me-no-dev)
  • AsyncTCP – Pflicht-Abhängigkeit auf dem ESP32
  • Adafruit BMP280 Library und Adafruit Unified Sensor
  • DHT sensor library von Adafruit
  • ArduinoJson (Version 7) für die Nachrichten

⚠️ Nicht die ESP8266-Variante erwischen. Es gibt ESPAsyncTCP (für den ESP8266) und AsyncTCP (für den ESP32). Wer die falsche installiert, bekommt beim Kompilieren einen Schwall unverständlicher Linker-Fehler. Für einen Wemos D1 Mini gilt das umgekehrt.

3Der erste WebSocket: Echo-Test

Bevor Diagramme kommen, prüfst du die Verbindung selbst. Dieser Sketch nimmt jede Nachricht entgegen und schickt sie zurück:

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

const char* WLAN_SSID = "DeinNetz";
const char* WLAN_PASS = "DeinPasswort";

AsyncWebServer   server(80);
AsyncWebSocket   ws("/ws");

void onWsEvent(AsyncWebSocket* srv, AsyncWebSocketClient* client,
               AwsEventType type, void* arg, uint8_t* data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("Client #%u verbunden von %s\n",
                    client->id(), client->remoteIP().toString().c_str());
      client->text("Willkommen beim ESP32");
      break;

    case WS_EVT_DISCONNECT:
      Serial.printf("Client #%u getrennt\n", client->id());
      break;

    case WS_EVT_DATA: {
      AwsFrameInfo* info = (AwsFrameInfo*)arg;
      if (info->final && info->index == 0 &&
          info->len == len && info->opcode == WS_TEXT) {
        data[len] = 0;                       // Nullterminierung
        Serial.printf("Empfangen: %s\n", (char*)data);
        client->text(String("Echo: ") + (char*)data);
      }
      break;
    }

    case WS_EVT_PONG:
    case WS_EVT_ERROR:
      break;
  }
}

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

  WiFi.mode(WIFI_STA);
  WiFi.begin(WLAN_SSID, WLAN_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print('.'); }
  Serial.print("\nIP: ");
  Serial.println(WiFi.localIP());

  ws.onEvent(onWsEvent);
  server.addHandler(&ws);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "text/plain", "WebSocket laeuft unter /ws");
  });

  server.begin();
}

void loop() {
  ws.cleanupClients();     // wichtig: raeumt tote Verbindungen auf
  delay(10);
}

💡 Schnelltest ohne eigene Webseite: Öffne im Browser eine beliebige Seite, drücke F12 und tippe in die Konsole: const s = new WebSocket('ws://192.168.1.50/ws'); s.onmessage = e => console.log(e.data); s.onopen = () => s.send('Hallo');. Die IP natürlich anpassen. Kommt das Echo zurück, steht die Verbindung.

4Sensorwerte als JSON pushen

Jetzt bekommt der ESP32 etwas zu sagen. Statt einzelner Zahlen schickst du ein kleines JSON-Objekt – damit bleibt das Protokoll erweiterbar, ohne dass du den Browser-Code jedes Mal umbauen musst:

#include <Wire.h>
#include <Adafruit_BMP280.h>
#include <DHT.h>
#include <ArduinoJson.h>

#define DHT_PIN    27
#define DHT_TYP    DHT22
#define RELAIS_PIN 26

Adafruit_BMP280 bmp;
DHT dht(DHT_PIN, DHT_TYP);

bool relaisAn = false;
unsigned long letzteMessung = 0;
const unsigned long MESSINTERVALL = 1000;

void sendeMesswerte() {
  float temp  = bmp.readTemperature();
  float druck = bmp.readPressure() / 100.0F;      // hPa
  float feuch = dht.readHumidity();

  if (isnan(feuch)) feuch = -1;                   // DHT22 mag Eile nicht

  JsonDocument doc;
  doc["typ"]     = "messwerte";
  doc["zeit"]    = millis();
  doc["temp"]    = roundf(temp  * 10) / 10.0;
  doc["druck"]   = roundf(druck * 10) / 10.0;
  doc["feuchte"] = roundf(feuch * 10) / 10.0;
  doc["relais"]  = relaisAn;
  doc["clients"] = ws.count();
  doc["heap"]    = ESP.getFreeHeap();

  String nachricht;
  serializeJson(doc, nachricht);
  ws.textAll(nachricht);        // an ALLE verbundenen Browser
}

void setup() {
  Serial.begin(115200);
  pinMode(RELAIS_PIN, OUTPUT);
  digitalWrite(RELAIS_PIN, LOW);

  Wire.begin(21, 22);
  if (!bmp.begin(0x76) && !bmp.begin(0x77)) {
    Serial.println("BMP280 nicht gefunden");
  }
  bmp.setSampling(Adafruit_BMP280::MODE_NORMAL,
                  Adafruit_BMP280::SAMPLING_X2,
                  Adafruit_BMP280::SAMPLING_X16,
                  Adafruit_BMP280::FILTER_X16,
                  Adafruit_BMP280::STANDBY_MS_500);
  dht.begin();

  // ... WLAN und WebSocket wie in Schritt 3 ...
}

void loop() {
  ws.cleanupClients();

  if (millis() - letzteMessung >= MESSINTERVALL) {
    letzteMessung = millis();
    if (ws.count() > 0) sendeMesswerte();   // nur senden, wenn jemand zuhoert
  }
}

ℹ️ Nur senden, wenn jemand zuhört. Die Abfrage ws.count() > 0 sieht nach einer Kleinigkeit aus, spart aber massiv Strom und Rechenzeit. Ohne offenen Browser misst der ESP32 gar nicht erst – gerade bei Akkubetrieb mit einem TP4056-Modul macht das den Unterschied zwischen Stunden und Tagen.

5Die Weboberfläche mit Live-Diagramm

Die Seite liegt als String im Flash. Sie holt Chart.js von einem CDN, baut ein rollendes Diagramm mit den letzten 60 Messwerten und hält sich über einen Reconnect-Timer selbst am Leben:

const char SEITE[] PROGMEM = R"HTML(
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 Live</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
 body{font-family:system-ui,sans-serif;background:#101520;color:#e0e6ed;margin:0;padding:20px}
 h1{font-size:20px;margin:0 0 4px}
 #status{font-size:13px;color:#8b95a5;margin-bottom:18px}
 .karten{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
 .karte{background:#141b2d;border:1px solid #252e42;border-radius:10px;padding:14px 18px;flex:1;min-width:120px}
 .karte .wert{font-size:26px;font-weight:700;color:#fff}
 .karte .label{font-size:12px;color:#8b95a5}
 button{padding:12px 20px;border:0;border-radius:8px;background:#3b82f6;color:#fff;font-size:15px;font-weight:600}
 button.an{background:#4ade80;color:#101520}
 canvas{background:#141b2d;border:1px solid #252e42;border-radius:10px;padding:8px}
</style></head><body>
<h1>ESP32 Live-Dashboard</h1>
<div id="status">verbinde ...</div>

<div class="karten">
  <div class="karte"><div class="wert" id="vTemp">--</div><div class="label">Temperatur C</div></div>
  <div class="karte"><div class="wert" id="vFeucht">--</div><div class="label">Feuchte %</div></div>
  <div class="karte"><div class="wert" id="vDruck">--</div><div class="label">Druck hPa</div></div>
</div>

<p><button id="btn">Relais schalten</button></p>
<canvas id="chart" height="120"></canvas>

<script>
const MAX = 60;
const chart = new Chart(document.getElementById('chart'), {
  type: 'line',
  data: { labels: [], datasets: [
    { label:'Temperatur', data:[], borderColor:'#3b82f6', tension:.3, pointRadius:0 },
    { label:'Feuchte',    data:[], borderColor:'#4ade80', tension:.3, pointRadius:0 }
  ]},
  options: {
    animation:false, responsive:true,
    scales:{ x:{ ticks:{color:'#8b95a5'}, grid:{color:'#252e42'} },
             y:{ ticks:{color:'#8b95a5'}, grid:{color:'#252e42'} } },
    plugins:{ legend:{ labels:{color:'#c5cdd8'} } }
  }
});

let ws;
function verbinde() {
  ws = new WebSocket('ws://' + location.host + '/ws');

  ws.onopen = () => { document.getElementById('status').textContent = 'verbunden'; };

  ws.onclose = () => {
    document.getElementById('status').textContent = 'getrennt - neuer Versuch in 2 s';
    setTimeout(verbinde, 2000);          // Reconnect
  };

  ws.onmessage = (e) => {
    let d;
    try { d = JSON.parse(e.data); } catch { return; }
    if (d.typ !== 'messwerte') return;

    document.getElementById('vTemp').textContent   = d.temp.toFixed(1);
    document.getElementById('vFeucht').textContent = d.feuchte.toFixed(1);
    document.getElementById('vDruck').textContent  = d.druck.toFixed(1);

    const btn = document.getElementById('btn');
    btn.className = d.relais ? 'an' : '';

    const t = new Date().toLocaleTimeString('de-DE');
    chart.data.labels.push(t);
    chart.data.datasets[0].data.push(d.temp);
    chart.data.datasets[1].data.push(d.feuchte);
    if (chart.data.labels.length > MAX) {
      chart.data.labels.shift();
      chart.data.datasets.forEach(ds => ds.data.shift());
    }
    chart.update('none');
  };
}

document.getElementById('btn').onclick = () => {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ befehl: 'relais_toggle' }));
  }
};

verbinde();
</script></body></html>
)HTML";

// Im setup() ausliefern:
server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
  req->send_P(200, "text/html", SEITE);
});

⚠️ Chart.js kommt vom CDN – also nur mit Internet. Solange dein Handy im selben WLAN und online ist, funktioniert das. In einem Netz ohne Internetzugang bleibt das Diagramm leer. Für diesen Fall legst du die Bibliothek zusammen mit der HTML-Datei in LittleFS ab – wie das geht, zeigt der Artikel ESP32 LittleFS. Auf Datenschutz-Seite heißt es außerdem: Ein CDN-Aufruf offenbart die IP deiner Besucher. Für ein Gerät im eigenen Heimnetz ist das unkritisch, für alles Öffentliche solltest du lokal ausliefern.

6Der Rückkanal: Relais aus dem Browser schalten

Jetzt die Hälfte, die Polling gar nicht kann. Der Browser schickt einen Befehl, der ESP32 führt ihn aus – und meldet den neuen Zustand an alle Clients zurück. Sind zwei Handys offen, sehen beide sofort dieselbe Realität:

void sendeStatus() {
  JsonDocument doc;
  doc["typ"]    = "status";
  doc["relais"] = relaisAn;
  String s;
  serializeJson(doc, s);
  ws.textAll(s);
}

void verarbeiteBefehl(AsyncWebSocketClient* client, const char* json) {
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, json);
  if (err) {
    client->text("{\"typ\":\"fehler\",\"text\":\"ungueltiges JSON\"}");
    return;
  }

  const char* befehl = doc["befehl"] | "";

  if (strcmp(befehl, "relais_toggle") == 0) {
    relaisAn = !relaisAn;
    digitalWrite(RELAIS_PIN, relaisAn ? HIGH : LOW);
    Serial.printf("Relais jetzt %s\n", relaisAn ? "AN" : "AUS");
    sendeStatus();                       // an alle, nicht nur an den Absender
  }
  else if (strcmp(befehl, "relais_setzen") == 0) {
    relaisAn = doc["wert"] | false;
    digitalWrite(RELAIS_PIN, relaisAn ? HIGH : LOW);
    sendeStatus();
  }
  else if (strcmp(befehl, "intervall") == 0) {
    uint32_t ms = doc["wert"] | 1000;
    Serial.printf("Neues Messintervall: %u ms\n", ms);
    // MESSINTERVALL als Variable statt const anlegen
  }
  else {
    client->text("{\"typ\":\"fehler\",\"text\":\"unbekannter Befehl\"}");
  }
}

// Im WS_EVT_DATA-Zweig aufrufen:
//   data[len] = 0;
//   verarbeiteBefehl(client, (char*)data);

💡 Immer den echten Zustand zurücksenden, nie den erwarteten. Der Browser sollte den Button erst umfärben, wenn die Bestätigung vom ESP32 eintrifft – nicht schon beim Klick. So siehst du sofort, wenn ein Befehl unterwegs verlorengeht, statt dich auf eine Anzeige zu verlassen, die nur behauptet, alles sei gut.

7Heartbeat: tote Verbindungen erkennen

Ein WLAN-Client, der aus der Reichweite fährt, meldet sich nicht ab. Der ESP32 hält die Verbindung dann noch minutenlang für aktiv und verbraucht Speicher dafür. Ein regelmäßiger Ping löst das:

unsigned long letzterPing = 0;
const unsigned long PING_INTERVALL = 15000;

void heartbeat() {
  if (millis() - letzterPing < PING_INTERVALL) return;
  letzterPing = millis();

  ws.cleanupClients();       // entfernt Clients ohne Pong
  ws.pingAll();              // Protokoll-Ping, vom Browser automatisch beantwortet

  Serial.printf("Clients: %u | freier Heap: %u Byte\n",
                ws.count(), ESP.getFreeHeap());
}

// Zusaetzliche Bremse gegen Ueberlastung:
void sendeWennMoeglich(const String& nachricht) {
  // Nur senden, wenn die Sendequeue nicht schon voll ist
  if (ws.availableForWriteAll()) {
    ws.textAll(nachricht);
  } else {
    Serial.println(F("Queue voll - Nachricht verworfen"));
  }
}

ℹ️ Wie viele Clients verträgt ein ESP32? In der Praxis vier bis acht gleichzeitig offene Browser – begrenzt wird das nicht durch die Rechenleistung, sondern durch den Heap. Jeder Client belegt Puffer. Beobachte ESP.getFreeHeap(): Fällt der Wert unter etwa 20.000 Byte, wird es kritisch. Braucht dein Projekt mehr, ist ein Board mit PSRAM wie das ESP32-S3 N16R8 die richtige Wahl.

8Status auf dem OLED

Praktisch beim Debuggen: Du siehst die IP-Adresse, ohne den seriellen Monitor zu öffnen – und ob überhaupt jemand verbunden ist:

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

Adafruit_SSD1306 oled(128, 64, &Wire, -1);

void oledStatus(float temp, float feuchte) {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);

  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.print(WiFi.localIP());
  oled.setCursor(96, 0);
  oled.printf("C:%u", ws.count());
  oled.drawFastHLine(0, 10, 128, SSD1306_WHITE);

  oled.setTextSize(2);
  oled.setCursor(0, 18);
  oled.print(temp, 1);
  oled.println(" C");

  oled.setCursor(0, 40);
  oled.print(feuchte, 1);
  oled.println(" %");

  // kleiner Aktivitaetspunkt rechts unten
  static bool blink = false;
  blink = !blink;
  if (blink) oled.fillCircle(122, 58, 3, SSD1306_WHITE);

  oled.display();
}

Troubleshooting

⚠️ Die Verbindung bricht nach etwa 30 Sekunden ab: Klassiker – der Heartbeat fehlt. Ohne Ping schließen manche Router und Browser inaktive Verbindungen. Baue ws.pingAll() alle 15 Sekunden ein.

⚠️ Im Browser steht „WebSocket connection failed“: Prüfe zuerst den Pfad. Der Handler wird mit AsyncWebSocket ws("/ws") registriert, der Browser muss also ws://IP/ws aufrufen. Ein fehlender Slash reicht für diesen Fehler.

⚠️ „Mixed Content“-Fehler in der Konsole: Wird die Seite über HTTPS geladen, verweigert der Browser eine unverschlüsselte ws://-Verbindung. Auf dem ESP32 lädst du die Seite über http:// – dann passt es. Tippe die IP direkt ein, damit der Browser nicht automatisch auf HTTPS umschreibt.

⚠️ Der ESP32 startet nach einigen Minuten neu: Speicherleck. Fast immer fehlt ws.cleanupClients() im loop(), oder es werden Nachrichten schneller erzeugt als gesendet. Beobachte den Heap-Wert, den der Sketch oben mitschickt.

⚠️ Der DHT22 liefert regelmäßig nan: Der Sensor braucht mindestens zwei Sekunden zwischen zwei Messungen. Bei einem Sendeintervall von einer Sekunde liest du ihn nur bei jedem zweiten Durchlauf. Der BMP280 hat diese Einschränkung nicht.

⚠️ Das Diagramm ruckelt auf dem Handy: Setze animation:false (im Beispiel oben schon getan) und rufe chart.update('none') auf. Bei mehr als 60 Punkten wird es auf älteren Geräten zäh.

⚠️ Nach dem Handy-Wechsel ins Mobilfunknetz und zurück bleibt die Seite leer: Genau dafür ist der Reconnect im onclose-Handler da. Fehlt er, muss man manuell neu laden – mit ihm passiert es von selbst.

Wohin es von hier aus geht

Das Grundmuster – JSON hin, JSON zurück, Zustand an alle Clients spiegeln – trägt weit. Ein paar naheliegende Erweiterungen:

  • Messwerte mitschreiben. Ein Micro-SD-Modul speichert den Verlauf, sodass das Diagramm beim Öffnen nicht bei null anfängt.
  • Mehrere Messpunkte. Zwei ESP32 tauschen ihre Werte per ESP-NOW aus, einer davon ist die Brücke zum Browser.
  • Ins Smart Home einbinden. Parallel zum WebSocket kannst du dieselben Werte per MQTT an Home Assistant schicken.
  • Zeitstempel statt millis(). Mit NTP bekommt jeder Messwert eine echte Uhrzeit.

🚀 Bauteile für dein Live-Dashboard

Alles aus diesem Tutorial:

Fazit

WebSockets sind der Punkt, an dem ein ESP32-Webserver aufhört, sich wie ein Bastelprojekt anzufühlen. Statt einer Seite, die man neu lädt, bekommst du ein Dashboard, das lebt – mit Werten, die in dem Moment erscheinen, in dem sie gemessen wurden, und Schaltern, die auf allen geöffneten Geräten gleichzeitig umspringen.

Vier Dinge solltest du dir merken: ws.cleanupClients() gehört in jeden loop(), ein Heartbeat verhindert Zombie-Verbindungen, ein Reconnect im onclose-Handler macht die Oberfläche alltagstauglich, und der Heap ist die Größe, die du im Blick behalten musst. Der Rest ist JSON – und das ist in beide Richtungen dieselbe Arbeit.

Zurück zum Blog