ESP-NOW mit ESP32: Zwei Boards ohne WLAN-Router vernetzen – Sensor-Funkstrecke bauen

Du willst einen Temperatursensor im Gartenhaus, aber dort ist kein WLAN? Oder zwei ESP32 sollen sich unterhalten, ohne dass ein Router dazwischenfunkt? Dann ist ESP-NOW genau dein Protokoll. Espressifs eigener Funkstandard verbindet ESP-Boards direkt miteinander – ohne Router, ohne WLAN-Passwort, ohne MQTT-Broker, ohne IP-Adressen. Die Latenz liegt bei wenigen Millisekunden, die Reichweite deutlich über normalem WLAN, und der Stromverbrauch ist so niedrig, dass ein Sensor-Node monatelang an einer 18650-Zelle hängt. In diesem Tutorial baust du eine komplette Funkstrecke auf: MAC-Adresse auslesen, Sender mit Sensor, Empfänger mit OLED, ein bidirektionaler Rückkanal – und am Ende ein Deep-Sleep-Node, der 99,9 % seiner Zeit schläft.

Warum ESP-NOW statt WLAN?

Der Standardweg für ESP32-Sensordaten läuft über WLAN: Board verbindet sich mit dem Router, holt sich per DHCP eine IP, öffnet eine Verbindung zum Broker, schickt Daten. Das funktioniert – kostet aber Zeit und Strom. Ein WLAN-Verbindungsaufbau dauert je nach Router 2 bis 5 Sekunden. Für einen batteriebetriebenen Sensor, der alle 10 Minuten einen Messwert schickt, sind diese Sekunden der mit Abstand größte Energiefresser.

ESP-NOW kennt keinen Verbindungsaufbau. Das Board wacht auf, schickt sein Paket direkt an die MAC-Adresse des Empfängers und schläft weiter. Der ganze Vorgang dauert unter 50 Millisekunden. Das ist grob Faktor 100 weniger Wachzeit – und damit der Unterschied zwischen „Akku alle zwei Wochen laden“ und „Akku hält ein halbes Jahr“.

ℹ️ Was ist ESP-NOW technisch? Espressif nutzt sogenannte Action Frames aus dem WLAN-Standard 802.11 – das sind Management-Pakete, die eigentlich für Verwaltungsaufgaben gedacht sind. Der Trick: Solche Frames dürfen ohne bestehende Verbindung verschickt werden. ESP-NOW packt bis zu 250 Byte Nutzdaten hinein und adressiert sie direkt an eine MAC-Adresse. Kein Handshake, kein DHCP, kein TCP – nur ein Funkpaket.

ESP-NOW, WLAN und BLE im Vergleich

Alle drei Funkwege haben ihre Berechtigung. Die Frage ist nicht „was ist besser“, sondern „was passt zu meinem Projekt“:

Kriterium ESP-NOW WLAN (TCP/MQTT) Bluetooth LE
Router nötig? Nein Ja Nein
Verbindungsaufbau entfällt 2–5 s ~100 ms
Latenz pro Paket < 10 ms 20–200 ms 30–100 ms
Nutzdaten pro Paket 250 Byte praktisch unbegrenzt ~20–500 Byte
Reichweite (frei) bis ~200 m ~50 m ~30 m
Peers gleichzeitig bis 20 (verschlüsselt: 6) viele wenige
Internet-Zugriff nein ja nein
Stromverbrauch pro Messung sehr niedrig hoch niedrig

💡 Die Kombi macht es: Der Klassiker ist ein Mischbetrieb – mehrere ESP-NOW-Sensor-Nodes funken an ein zentrales Gateway-Board, und nur dieses Gateway hängt im WLAN und schiebt die gesammelten Daten weiter ins Netz. Die Sensoren bleiben stromsparend, die Daten landen trotzdem im Smart Home.

Was du brauchst

2x ESP32 DEV KIT (WROOM-32, USB-C) – einer als Sender, einer als Empfänger – Zum Produkt
Alternativ: 2x ESP32-C3 Mini – kleiner, sparsamer, ideal als Sensor-Node – Zum Produkt
1x AM2302 (DHT22) Temperatur- & Feuchtigkeitssensor – Zum Produkt
1x 0.96 Zoll OLED Display SSD1306 (I2C) für den Empfänger – Zum Produkt
2x Solderless Breadboard 400 Kontakte – Zum Produkt
1x TP4056 Lademodul (für den batteriebetriebenen Node) – Zum Produkt
1x MT3608 Step-Up Modul (3,7 V Akku auf 5 V) – Zum Produkt

ℹ️ Board-Mix erlaubt: ESP-NOW ist innerhalb der Espressif-Familie kompatibel. Ein ESP32 kann problemlos an einen ESP32-C3, ESP32-S3, ESP32-C6 oder sogar einen ESP8266 funken. Nur bei ESP8266 als Empfänger gibt es kleine Unterschiede in der Callback-Signatur – der Code unten ist für ESP32-Boards geschrieben.

1MAC-Adresse des Empfängers auslesen

ESP-NOW adressiert Pakete direkt an eine MAC-Adresse – die eindeutige Hardware-Kennung des Funkchips. Bevor du irgendetwas senden kannst, brauchst du also die MAC des Empfänger-Boards. Lade diesen Mini-Sketch auf das Board, das später der Empfänger sein soll:

#include <WiFi.h>

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

  // WiFi in den Station-Modus versetzen, sonst liefert
  // macAddress() nicht die richtige Adresse
  WiFi.mode(WIFI_STA);

  Serial.println();
  Serial.print("MAC-Adresse dieses Boards: ");
  Serial.println(WiFi.macAddress());
  Serial.println("Diese Adresse in den Sender-Sketch eintragen!");
}

void loop() {
  // nichts zu tun
}

Öffne den seriellen Monitor bei 115200 Baud und drücke die RESET-Taste. Du bekommst etwas wie A0:B7:65:2D:4E:1C. Notiere dir diese sechs Byte – sie wandern gleich in den Sender-Code.

⚠️ Vorsicht bei der Schreibweise: Der serielle Monitor zeigt die MAC mit Doppelpunkten, im C++-Code brauchst du ein Byte-Array mit 0x-Präfix. Aus A0:B7:65:2D:4E:1C wird also {0xA0, 0xB7, 0x65, 0x2D, 0x4E, 0x1C}. Ein einziges vertauschtes Byte, und die Pakete verschwinden spurlos – ohne Fehlermeldung.

2Verkabelung

Der Sender bekommt den DHT22, der Empfänger das OLED-Display. Beides ist unkritisch:

Sender: ESP32 + DHT22

DHT22 / AM2302 ESP32 Hinweis
VCC (+) 3V3 3,3 V reichen aus
DATA (OUT) GPIO 4 Modulversion hat Pull-Up an Bord
GND (–) GND gemeinsame Masse

Empfänger: ESP32 + OLED SSD1306

OLED SSD1306 ESP32 Hinweis
VCC 3V3 Display läuft mit 3,3 V
GND GND
SDA GPIO 21 I2C-Datenleitung
SCL GPIO 22 I2C-Taktleitung

3Der Sender: Sensordaten verschicken

Das Herzstück von ESP-NOW ist eine Struktur. Statt Strings zusammenzubasteln, definierst du ein struct mit deinen Messwerten und schickst es als rohe Bytes über den Äther. Sender und Empfänger müssen dieselbe Struktur kennen – dann passt alles automatisch.

Installiere vorher über den Bibliotheksverwalter die DHT sensor library von Adafruit (inklusive der Abhängigkeit Adafruit Unified Sensor).

#include <esp_now.h>
#include <WiFi.h>
#include <DHT.h>

#define DHTPIN  4
#define DHTTYPE DHT22
DHT dht(DHTPIN, DHTTYPE);

// MAC-Adresse des Empfaengers - HIER DEINE EINTRAGEN!
uint8_t empfaengerMac[] = {0xA0, 0xB7, 0x65, 0x2D, 0x4E, 0x1C};

// Diese Struktur muss auf BEIDEN Boards identisch sein
typedef struct {
  uint8_t  nodeId;      // welcher Sensor hat gefunkt?
  float    temperatur;
  float    luftfeuchte;
  uint32_t zaehler;     // laufende Nummer, um Verluste zu erkennen
} SensorPaket;

SensorPaket paket;
uint32_t sendeZaehler = 0;

// Callback: wird aufgerufen, sobald das Paket raus ist
void onDataSent(const uint8_t *mac, esp_now_send_status_t status) {
  Serial.print("Sendestatus: ");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "OK" : "FEHLGESCHLAGEN");
}

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

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();   // sicherstellen, dass keine WLAN-Verbindung stoert

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW konnte nicht gestartet werden!");
    return;
  }

  esp_now_register_send_cb(onDataSent);

  // Empfaenger als Peer registrieren
  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, empfaengerMac, 6);
  peer.channel = 0;        // 0 = aktueller WiFi-Kanal
  peer.encrypt = false;

  if (esp_now_add_peer(&peer) != ESP_OK) {
    Serial.println("Peer konnte nicht hinzugefuegt werden!");
    return;
  }

  Serial.println("Sender bereit.");
}

void loop() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();

  if (isnan(t) || isnan(h)) {
    Serial.println("DHT22 liefert keine Daten - Verkabelung pruefen.");
    delay(2000);
    return;
  }

  paket.nodeId      = 1;
  paket.temperatur  = t;
  paket.luftfeuchte = h;
  paket.zaehler     = ++sendeZaehler;

  esp_err_t ergebnis = esp_now_send(empfaengerMac,
                                    (uint8_t *) &paket,
                                    sizeof(paket));

  if (ergebnis == ESP_OK) {
    Serial.printf("Gesendet #%lu: %.1f C / %.1f %%\n", paket.zaehler, t, h);
  } else {
    Serial.println("Fehler beim Senden.");
  }

  delay(5000);
}

ℹ️ Was macht peer.channel = 0? Damit sagst du: „Nimm den Kanal, auf dem das Board gerade sowieso funkt.“ Solange beide Boards frisch gestartet sind und keine WLAN-Verbindung haben, landen sie auf Kanal 1 und verstehen sich. Sobald ein Board zusätzlich in einem echten WLAN hängt, wird der Kanal vom Router vorgegeben – dann musst du beide Seiten explizit auf denselben Kanal zwingen. Mehr dazu weiter unten im Troubleshooting.

4Der Empfänger: Daten auf dem OLED anzeigen

Der Empfänger ist noch einfacher – er registriert einen Callback und wartet. Kein Peer nötig, keine Adresse, nichts. Wer ihn anfunkt, kommt durch.

Installiere die Bibliotheken Adafruit SSD1306 und Adafruit GFX Library über den Bibliotheksverwalter.

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

#define BREITE 128
#define HOEHE   64
Adafruit_SSD1306 display(BREITE, HOEHE, &Wire, -1);

// IDENTISCH zur Struktur im Sender!
typedef struct {
  uint8_t  nodeId;
  float    temperatur;
  float    luftfeuchte;
  uint32_t zaehler;
} SensorPaket;

SensorPaket empfangen;
unsigned long letzterEmpfang = 0;
uint32_t erwarteteNummer = 0;
uint32_t verloren = 0;

// Callback: wird bei jedem eingehenden Paket aufgerufen
void onDataRecv(const esp_now_recv_info_t *info,
                const uint8_t *daten, int laenge) {

  if (laenge != sizeof(SensorPaket)) {
    Serial.println("Paket hat unerwartete Groesse - ignoriert.");
    return;
  }

  memcpy(&empfangen, daten, sizeof(empfangen));
  letzterEmpfang = millis();

  // Paketverluste zaehlen
  if (erwarteteNummer != 0 && empfangen.zaehler > erwarteteNummer) {
    verloren += (empfangen.zaehler - erwarteteNummer);
  }
  erwarteteNummer = empfangen.zaehler + 1;

  Serial.printf("Node %u: %.1f C / %.1f %% (Paket #%lu)\n",
                empfangen.nodeId,
                empfangen.temperatur,
                empfangen.luftfeuchte,
                empfangen.zaehler);
}

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

  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("OLED nicht gefunden - Adresse 0x3C pruefen.");
    while (true) delay(100);
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println("Warte auf Daten...");
  display.display();

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW Start fehlgeschlagen!");
    return;
  }

  esp_now_register_recv_cb(onDataRecv);
  Serial.print("Empfaenger bereit. Meine MAC: ");
  Serial.println(WiFi.macAddress());
}

void loop() {
  display.clearDisplay();
  display.setCursor(0, 0);

  if (letzterEmpfang == 0) {
    display.setTextSize(1);
    display.println("Warte auf Daten...");
  } else {
    unsigned long alter = (millis() - letzterEmpfang) / 1000;

    display.setTextSize(1);
    display.printf("Node %u\n", empfangen.nodeId);

    display.setTextSize(2);
    display.printf("%.1f C\n", empfangen.temperatur);
    display.printf("%.0f %%\n", empfangen.luftfeuchte);

    display.setTextSize(1);
    display.printf("vor %lus | verl. %lu", alter, verloren);

    // Warnung, wenn laenger nichts kam
    if (alter > 30) {
      display.setCursor(0, 56);
      display.print("!! Node offline ?");
    }
  }

  display.display();
  delay(500);
}

Startest du jetzt beide Boards, tickern im seriellen Monitor des Empfängers die Messwerte durch, und das OLED zeigt Temperatur, Luftfeuchte, das Alter des letzten Pakets und einen Verlustzähler. Der Verlustzähler ist Gold wert: Damit siehst du beim Verschieben des Senders sofort, wo die Reichweite endet.

💡 Callback-Signatur beachten: Mit dem ESP32-Core ab Version 3.x heißt der erste Parameter const esp_now_recv_info_t *info. In älteren 2.x-Cores war es const uint8_t *mac. Wenn dein Compiler über den Callback meckert, ist fast immer die Core-Version schuld – im Boardverwalter nachsehen, welche „esp32 by Espressif Systems“-Version installiert ist.

5Bidirektional: Der Empfänger antwortet

Bisher ist die Strecke eine Einbahnstraße. ESP-NOW kann aber in beide Richtungen – jedes Board darf gleichzeitig Sender und Empfänger sein. Praktisches Beispiel: Der Empfänger schickt eine Bestätigung zurück und sagt dem Sensor-Node, wie lange er bis zur nächsten Messung schlafen soll. So steuerst du das Messintervall zentral, ohne den Node neu zu flashen.

Ergänze im Empfänger die Rückrichtung:

// --- Zusaetzlich im EMPFAENGER ---

typedef struct {
  bool     empfangBestaetigt;
  uint16_t naechstesIntervallSek;  // wie lange soll der Node schlafen?
} Antwort;

Antwort antwort;
bool peerAngelegt = false;

void onDataRecv(const esp_now_recv_info_t *info,
                const uint8_t *daten, int laenge) {

  if (laenge != sizeof(SensorPaket)) return;
  memcpy(&empfangen, daten, sizeof(empfangen));
  letzterEmpfang = millis();

  // Absender beim ersten Kontakt automatisch als Peer anlegen
  if (!peerAngelegt) {
    esp_now_peer_info_t peer = {};
    memcpy(peer.peer_addr, info->src_addr, 6);
    peer.channel = 0;
    peer.encrypt = false;
    if (esp_now_add_peer(&peer) == ESP_OK) {
      peerAngelegt = true;
      Serial.println("Absender als Peer registriert.");
    }
  }

  // Antwort zusammenbauen: bei Hitze oefter messen
  antwort.empfangBestaetigt = true;
  antwort.naechstesIntervallSek =
      (empfangen.temperatur > 28.0) ? 60 : 600;

  esp_now_send(info->src_addr, (uint8_t *) &antwort, sizeof(antwort));

  Serial.printf("Antwort geschickt: naechste Messung in %u s\n",
                antwort.naechstesIntervallSek);
}

Und im Sender nimmst du die Antwort entgegen:

// --- Zusaetzlich im SENDER ---

typedef struct {
  bool     empfangBestaetigt;
  uint16_t naechstesIntervallSek;
} Antwort;

volatile uint16_t schlafSekunden = 600;   // Startwert
volatile bool     antwortDa      = false;

void onDataRecv(const esp_now_recv_info_t *info,
                const uint8_t *daten, int laenge) {
  if (laenge != sizeof(Antwort)) return;

  Antwort a;
  memcpy(&a, daten, sizeof(a));

  if (a.empfangBestaetigt) {
    schlafSekunden = a.naechstesIntervallSek;
    antwortDa = true;
    Serial.printf("Gateway sagt: naechste Messung in %u s\n", schlafSekunden);
  }
}

// im setup() zusaetzlich registrieren:
// esp_now_register_recv_cb(onDataRecv);

⚠️ Im Callback nichts Langes tun: Die Empfangs-Callbacks laufen in einem WLAN-Task mit hoher Priorität. Lange delay()-Aufrufe, Serial.print()-Orgien oder Display-Updates darin bringen den Funkstack ins Stocken und kosten dich Pakete. Merke dir im Callback nur die Daten in einer volatile-Variablen und verarbeite sie im loop(). Das Display-Update oben steht bewusst im loop(), nicht im Callback.

6Der Batterie-Node: Deep-Sleep + ESP-NOW

Jetzt kommt der Teil, für den man ESP-NOW eigentlich nimmt. Der Node wacht auf, misst, funkt, schläft. Alles zusammen unter 200 Millisekunden Wachzeit – der Rest der Zeit zieht der ESP32 im Deep-Sleep nur wenige Mikroampere.

#include <esp_now.h>
#include <WiFi.h>
#include <DHT.h>

#define DHTPIN  4
#define DHTTYPE DHT22
#define uS_PRO_SEKUNDE 1000000ULL

DHT dht(DHTPIN, DHTTYPE);

uint8_t empfaengerMac[] = {0xA0, 0xB7, 0x65, 0x2D, 0x4E, 0x1C};

typedef struct {
  uint8_t  nodeId;
  float    temperatur;
  float    luftfeuchte;
  uint32_t zaehler;
} SensorPaket;

SensorPaket paket;

// ueberlebt den Deep-Sleep, weil im RTC-Speicher
RTC_DATA_ATTR uint32_t bootZaehler = 0;

volatile bool sendenFertig = false;

void onDataSent(const uint8_t *mac, esp_now_send_status_t status) {
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "OK" : "FEHLER");
  sendenFertig = true;   // Signal: wir duerfen schlafen
}

void schlafeJetzt(uint32_t sekunden) {
  Serial.printf("Schlafe %lu Sekunden...\n", sekunden);
  Serial.flush();
  esp_sleep_enable_timer_wakeup(sekunden * uS_PRO_SEKUNDE);
  esp_deep_sleep_start();
  // ab hier laeuft nichts mehr - nach dem Aufwachen startet setup() neu
}

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

  dht.begin();
  delay(250);            // DHT22 braucht kurz, bis er bereit ist

  float t = dht.readTemperature();
  float h = dht.readHumidity();

  if (isnan(t) || isnan(h)) {
    Serial.println("Messung fehlgeschlagen - kurzer Retry-Schlaf.");
    schlafeJetzt(60);
  }

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW Init fehlgeschlagen.");
    schlafeJetzt(60);
  }

  esp_now_register_send_cb(onDataSent);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, empfaengerMac, 6);
  peer.channel = 0;
  peer.encrypt = false;
  esp_now_add_peer(&peer);

  paket.nodeId      = 1;
  paket.temperatur  = t;
  paket.luftfeuchte = h;
  paket.zaehler     = bootZaehler;

  esp_now_send(empfaengerMac, (uint8_t *) &paket, sizeof(paket));
  Serial.printf("Boot #%lu: %.1f C / %.1f %%\n", bootZaehler, t, h);

  // auf den Sende-Callback warten, aber maximal 200 ms
  unsigned long start = millis();
  while (!sendenFertig && millis() - start < 200) {
    delay(1);
  }

  schlafeJetzt(600);   // 10 Minuten
}

void loop() {
  // wird nie erreicht - nach dem Deep-Sleep startet setup() neu
}

ℹ️ Warum RTC_DATA_ATTR? Ein Deep-Sleep ist praktisch ein Neustart: Der Hauptspeicher wird gelöscht, setup() läuft von vorn. Nur der RTC-Speicher überlebt. Variablen mit RTC_DATA_ATTR landen dort und behalten ihren Wert – perfekt für einen Boot-Zähler oder den letzten Messwert. Das loop() wird bei diesem Muster übrigens nie erreicht: Der Chip schläft am Ende von setup() ein und startet danach wieder bei setup().

Zur Stromversorgung: Eine 18650-Zelle hängt am TP4056-Lademodul (Laden per USB-C, mit Schutzschaltung gegen Tiefentladung), dahinter hebt ein MT3608 Step-Up-Modul die 3,7 V auf saubere 5 V für den VIN-Pin. Wie du diese Kette richtig aufbaust, steht ausführlich im Artikel TP4056 & MT3608: Akkubetriebene ESP32-Projekte bauen.

Reichweite, Kanäle und mehrere Nodes

Reichweite realistisch einschätzen

Die oft genannten „bis zu 200 Meter“ gelten für freie Sicht mit guten Antennen. In der Praxis solltest du mit diesen Hausnummern rechnen:

Umgebung realistische Reichweite
Freie Sicht, Board auf Board 100–200 m
Garten, eine Hauswand dazwischen 30–60 m
Wohnung, zwei bis drei Zimmerwände 15–30 m
Stahlbetondecke dazwischen 5–15 m
Metallgehäuse ohne externe Antenne nahezu 0 m

Mehrere Sensor-Nodes an ein Gateway

Genau dafür ist das nodeId-Feld im Paket da. Jeder Node bekommt eine eigene Nummer, das Gateway sortiert die Daten anhand dieser ID. Der Empfänger-Code oben braucht dafür keine Änderung – er nimmt einfach alles entgegen, was hereinkommt. Erweitere ihn um ein Array:

#define MAX_NODES 8

struct NodeStatus {
  bool          aktiv;
  float         temperatur;
  float         luftfeuchte;
  unsigned long letzterEmpfang;
};

NodeStatus nodes[MAX_NODES];

// im Empfangs-Callback:
if (empfangen.nodeId < MAX_NODES) {
  nodes[empfangen.nodeId].aktiv          = true;
  nodes[empfangen.nodeId].temperatur     = empfangen.temperatur;
  nodes[empfangen.nodeId].luftfeuchte    = empfangen.luftfeuchte;
  nodes[empfangen.nodeId].letzterEmpfang = millis();
}

ESP-NOW erlaubt bis zu 20 registrierte Peers gleichzeitig (nur 6, wenn du Verschlüsselung nutzt). Für den reinen Empfang brauchst du gar keine Peers – die Begrenzung greift erst, wenn du selbst an viele Nodes zurücksenden willst.

Troubleshooting: Wenn nichts ankommt

⚠️ Sendestatus sagt „OK“, aber der Empfänger sieht nichts: Der häufigste Fall. ESP_NOW_SEND_SUCCESS bedeutet nur, dass ein Gerät auf Funkebene den Empfang quittiert hat – nicht, dass dein Empfänger es war. Prüfe die MAC-Adresse Byte für Byte, und stelle sicher, dass beide Boards auf demselben Kanal sind.

⚠️ Kanal-Problem bei Gateway-Betrieb: Sobald ein Board zusätzlich per WiFi.begin() im echten WLAN hängt, zwingt der Router es auf seinen Kanal – und ESP-NOW folgt mit. Der Sender funkt aber weiter auf Kanal 1 und wird nie gehört. Lösung: Lies am Gateway den Kanal mit WiFi.channel() aus und stelle den Sender mit esp_wifi_set_channel(kanal, WIFI_SECOND_CHAN_NONE); fest auf denselben Wert. Alternativ setzt du den Router auf einen festen Kanal und trägst diesen auf beiden Seiten hart ein.

⚠️ „ESP-NOW Init fehlgeschlagen“: Fast immer fehlt WiFi.mode(WIFI_STA) vor esp_now_init(). Ohne aktiven WLAN-Modus gibt es keinen Funkstack, den ESP-NOW nutzen könnte.

⚠️ Empfänger bekommt Müll-Werte: Sender und Empfänger nutzen unterschiedliche Strukturen. Schon ein zusätzliches Feld oder ein int statt uint8_t verschiebt alle Bytes. Kopiere das struct wortwörtlich in beide Sketches – und behalte die Größenprüfung if (laenge != sizeof(SensorPaket)) drin, die genau solche Fehler abfängt.

⚠️ Pakete verschwinden sporadisch: ESP-NOW hat keine automatische Wiederholung wie TCP. Bei Störungen ist das Paket weg. Wenn dir das wichtig ist, baue dir eine eigene Bestätigung (siehe Schritt 5) oder sende Messwerte einfach zweimal – bei 250 Byte pro Paket ist das billiger als jede komplexe Fehlerkorrektur.

ℹ️ Verschlüsselung nachrüsten: ESP-NOW kann Pakete mit einem 16 Byte langen Schlüssel verschlüsseln. Du setzt dazu einen Primärschlüssel per esp_now_set_pmk() und pro Peer einen lokalen Schlüssel in peer.lmk, dann peer.encrypt = true. Für Temperaturwerte im eigenen Garten ist das meist übertrieben – sobald aber ein Schaltbefehl über die Strecke geht (Tür auf, Heizung an), solltest du verschlüsseln. Sonst reicht ein beliebiger ESP32 in Funkreichweite, um deine Pakete nachzubauen.

Praxistipps aus der Werkstatt

  • MAC fest verdrahten: Klebe dir ein kleines Etikett mit der MAC-Adresse direkt auf jedes Board. Nach dem dritten Node weiß niemand mehr, welche Adresse zu welchem Gehäuse gehört.
  • Broadcast zum Testen: Die Adresse {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF} ist die Broadcast-MAC – ein Paket dorthin hören alle ESP-NOW-Geräte in Reichweite. Ideal zum Debuggen, wenn du dir bei der Ziel-MAC unsicher bist. Im Dauerbetrieb aber unschön, weil jeder mithört.
  • Zähler nicht vergessen: Das zaehler-Feld kostet vier Byte und verrät dir jederzeit deine Paketverlustrate. Ohne diesen Zähler tappst du bei Reichweitenproblemen im Dunkeln.
  • Antenne freihalten: Der Mäander auf der ESP32-Platine ist die Antenne. Sie darf nicht über der Breadboard-Metallschiene liegen und nicht in ein Metallgehäuse. Ein Acryl-Gehäuse ist funktechnisch unproblematisch.
  • Erst auf dem Tisch, dann in den Garten: Bring die Strecke mit beiden Boards nebeneinander zum Laufen, bevor du den Node nach draußen bringst. Sonst suchst du gleichzeitig nach Code- und Reichweitenfehlern.
  • ESP32-C3 als Node: Für reine Sensor-Nodes ist der ESP32-C3 Mini die bessere Wahl – kleiner, günstiger und im Deep-Sleep sparsamer als der WROOM-32. Der Code oben läuft dort unverändert.

🚀 Deine ESP-NOW-Funkstrecke bei MAKEROO

Alle Bauteile für Sender, Empfänger und Batterie-Node:

Fazit

ESP-NOW ist eines der Features, bei denen man sich nach dem ersten funktionierenden Test fragt, warum man es nicht schon längst benutzt. Kein Router, kein Passwort, keine IP-Verwaltung – ein Byte-Array an eine MAC-Adresse, fertig. Genau diese Schlichtheit macht es so stark: Weil kein Verbindungsaufbau nötig ist, sinkt die Wachzeit eines Sensor-Nodes von Sekunden auf Millisekunden, und plötzlich ist Batteriebetrieb über Monate keine Bastelei mehr, sondern der Normalfall.

Die Grenzen solltest du trotzdem kennen: 250 Byte pro Paket, keine automatische Wiederholung, kein Internet. Für Sensordaten, Schaltbefehle und Fernbedienungen ist das völlig ausreichend – für Bilder oder große Datenmengen nimmst du weiterhin WLAN. Der Königsweg ist der Mischbetrieb: viele sparsame ESP-NOW-Nodes funken an ein Gateway, und nur dieses eine Board hängt am Strom und im WLAN. Damit hast du das Beste aus beiden Welten – und der Sensor im Gartenhaus meldet sich zuverlässig, ganz ohne WLAN-Ausbau nach draußen.

Zurück zum Blog