Guides · 22 min read

ESP-NOW Tutorial: ESP32 Wireless Communication Without a Router

ESP-NOW with the ESP32: find the MAC address, send a struct up to 250 bytes one-way or two-way, use up to 20 peers, range, core 3.x changes and Wi-Fi channels.

ESP-NOW is a wireless protocol developed by Espressif that lets ESP32 and ESP8266 boards send data straight to each other without a router, access point or internet connection. The boards use their Wi-Fi radio but never join a network; each device finds the other by its MAC address and delivers small packets of up to 250 bytes in a few milliseconds. Because there's no connection to set up, no IP address to obtain and no password to enter, a button press on one board can switch a relay on another board instantly, and a battery-powered sensor can wake up, send its reading and go straight back to sleep.

In this guide you'll set up ESP-NOW from scratch: first you'll read your boards' MAC addresses, then use one-way and two-way examples to send different values such as temperature, a counter and text in a single packet using a struct. After that you'll learn about the 20-peer limit, broadcast messages, range and speed, the functions that changed with ESP32 Arduino core 3.x, and channel matching, the one rule for using ESP-NOW and Wi-Fi at the same time. In the last part you'll send the readings of a battery-powered remote sensor in deep sleep to a gateway connected to Wi-Fi and watch them from your phone. If this is your first ESP32 project, finish the Arduino IDE setup in What Is the ESP32? first.

ESP32 development board communicating with ESP-NOW
ESP-NOW needs no extra module: the Wi-Fi radio inside the ESP32 lets the boards talk directly without a router.

What is ESP-NOW and how does it work?

With normal Wi-Fi, the ESP32 first connects to the router, checks the password, gets an IP address via DHCP and only then can it send data; this usually takes 1–3 seconds. ESP-NOW skips all of these steps. It puts your data inside a short Wi-Fi packet called a vendor-specific action frame from the IEEE 802.11 standard and sends it directly to the other board's MAC address. The receiving board answers with a hardware acknowledgement (ACK) frame, and a callback function on the sending side tells you whether the packet was delivered. If no ACK arrives, the Wi-Fi layer automatically retries the packet a few times. In short, ESP-NOW works like an invisible mailbox between two boards: you write the address, drop the packet in, and you hear back when the other side has received it.

FeatureValue
Developed byEspressif Systems
Frequency2.4 GHz (Wi-Fi radio, channels 1–13)
Data per packet250 bytes (ESP-NOW v1); 1470 bytes with v2 on ESP-IDF 5.4 and later
Registered peersUp to 20; up to 7 encrypted peers by default
Default data rate1 Mbps
Delivery acknowledgementYes for unicast (ACK), none for broadcast
EncryptionOptional, CCMP (AES-128); PMK and LMK keys of 16 bytes each
Router / access pointNot needed
Supported chipsESP32 chips with Wi-Fi (ESP32, S2, S3, C2, C3, C6…) and ESP8266

The real advantages of ESP-NOW are fast response and low energy use. When you press a button, the LED on the other side lights up within a few milliseconds; doing the same over Wi-Fi means connecting first and then sending a request to a server. The difference is even bigger for a battery-powered sensor: while connecting to Wi-Fi, the radio draws more than 100 mA for seconds, whereas with ESP-NOW the board wakes up, sends its reading and goes back to sleep within a few hundred milliseconds in total. On the other hand, ESP-NOW doesn't reach the internet; if you want to get your data to the internet or your phone, there must be a gateway ESP32 connected to Wi-Fi somewhere in the network. We'll build that setup later in this guide.

ESP-NOW vs Wi-Fi vs Bluetooth vs nRF24L01

MethodExtra moduleRouterPacket sizeTypical range (open space)Best for
ESP-NOWNot neededNot needed250 bytes100–200 m, more in LR modeCommands and sensor data between boards
Wi-Fi (HTTP, MQTT)Not neededRequiredPractically unlimitedThe router's coverageInternet, web pages, cloud
Bluetooth (BLE)Not neededNot needed20 bytes by default10–20 m indoorsConnecting to a phone
nRF24L01Required (SPI)Not needed32 bytesTens of metres, much more with the PA+LNA versionBoards without wireless such as the Arduino Uno and Nano
LoRa (SX1278)Required (SPI)Not neededUp to 255 bytesSeveral kilometresVery long distances, infrequent data

If you need to talk to a phone, Bluetooth is the right choice; if you need the internet, use Wi-Fi. But if you have several ESP32 boards that only need to talk to each other, ESP-NOW is usually the simplest way: no extra module, cable or router, and only a few lines of code. For boards without wireless like the Arduino Uno, the nRF24L01 is a better fit, and for kilometres of range, LoRa modules. To compare all the options side by side, see Arduino wireless communication options, and for connecting to a phone, check our ESP32 Bluetooth guide.

Which boards support ESP-NOW?

Board / chipESP-NOWNote
ESP32 (WROOM-32, DevKit)YesAlso supports Long Range (LR) mode
ESP32-S2, S3, C3, C6YesSame Arduino code, only the board selection changes
ESP32-C2 (ESP8684)YesNo LR mode
ESP32-H2NoNo Wi-Fi radio, so no ESP-NOW
ESP8266 (NodeMCU, Wemos D1 Mini)YesDifferent library (espnow.h), v1 and 250 bytes only
Raspberry Pi Pico W / Pico 2 WNoThe Wi-Fi chip (CYW43439) can't run ESP-NOW
Arduino Uno, Nano, MegaNoCan be connected to an ESP32 over a serial port (UART)

ESP-NOW also works between different ESP chips: an ESP32-C3 sensor can send packets to a classic ESP32 gateway or to an ESP8266 board. The only requirements are that both sides are on the same Wi-Fi channel and that the data structure is defined exactly the same on both sides. The examples below use two ESP32 DevKit boards; you can see which pins of your board are safe to use in the Pinout tool.

Step 1: Find the ESP32's MAC address

In ESP-NOW each board finds the other by its 6-byte MAC address; this address is written into every ESP32 at the factory and never changes. Upload the sketch below to the board you'll use as the receiver and open the Serial Monitor at 115200 baud. It prints the address both in readable form and as an array line you can paste straight into the other board's code. For the two-board examples, do this for both boards and write the addresses down.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_wifi.h>
3
4void setup() {
5  Serial.begin(115200);
6  delay(1000);
7  WiFi.mode(WIFI_STA);                 // ESP-NOW istasyon arayüzünü kullanır / ESP-NOW uses the station interface
8
9  uint8_t mac[6];
10  if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) {
11    Serial.printf("STA MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
12                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
13    // Diğer karta kopyalanacak satır / line to paste into the other board's code
14    Serial.printf("uint8_t karsiMac[] = {0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X};\n",
15                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
16  } else {
17    Serial.println("MAC okunamadi / could not read MAC");
18  }
19}
20
21void loop() {
22}
The ESP32 has two separate Wi-Fi MAC addresses: station (STA) and access point (AP). The AP address is usually the STA address with the last byte increased by one. All the examples in this guide run the boards in WIFI_STA mode, so always enter the STA address on the other board; using the address of the wrong interface is the most common reason for the "failed" message. On ESP32 Arduino core 3.x, WiFi.macAddress() can return 00:00:00:00:00:00 if it's called before Wi-Fi has fully started; the esp_wifi_get_mac() call in the sketch above doesn't have this problem.

Sending data with a struct and the 250-byte limit

ESP-NOW only sends an array of bytes and has no idea what's inside. The cleanest way to send several different values at once is to group them in a struct and send the struct's bytes exactly as they sit in memory. The receiver defines the same struct and copies the incoming bytes onto it with memcpy. For this to work, the struct must have the same order, the same types and the same size on both sides. The compiler may insert invisible padding bytes between fields for faster access: the Paket struct in the examples below normally takes 36 bytes, but drops to exactly 30 bytes once you add __attribute__((packed)). Using packed guarantees that every byte sits in the same place between an ESP32 and an ESP8266, or between Arduino and MicroPython.

TypeSizeNote
bool, uint8_t, int8_t, char1 byteFlag, small number, single character
int16_t, uint16_t2 bytes−32768…32767 or 0…65535
int, int32_t, uint32_t4 bytesint is 4 bytes on the ESP32 and ESP8266, but 2 bytes on the Arduino Uno
float4 bytesAbout 7 digits of precision
double8 bytesUse float unless you really need it
char mesaj[16]16 bytesFixed-length text; must end with '\0'
String, char*, pointerCan't be sentOnly a memory address goes out, which is meaningless on the other side

A packet holds at most 250 bytes. This limit comes from the first version of ESP-NOW (v1) and applies to every board, including the ESP8266. Arduino core 3.2 and later, based on ESP-IDF 5.4, can send packets of up to 1470 bytes with ESP-NOW v2; however, a board running v1 (an ESP8266 or an ESP32 with an older core) truncates packets longer than 250 bytes or drops them entirely. So in a mixed network, staying within 250 bytes is the safest choice. The static_assert line in the sender sketch stops compilation with a clear message if the struct grows beyond 250 bytes. If you need to send bigger data, split it into numbered chunks and reassemble them in order on the receiver; for sensor values, button states or commands, a few dozen bytes are more than enough.

One-way ESP-NOW: the sender sketch

  1. Make sure both ESP32 boards are selected as ESP32 Dev Module in the Arduino IDE. The ESP-NOW library is part of the board package, so there's nothing else to install.
  2. Enter the MAC address you read from the receiver board into the aliciMac array in the code below.
  3. Connect a push button between GPIO 4 and GND on the sender; since we use INPUT_PULLUP, no extra resistor is needed. For details see How to connect a button to an Arduino.
  4. Upload the sketch to the sender and open the Serial Monitor at 115200 baud. While the receiver isn't running yet, it's normal to see "Teslim edilemedi / failed" every two seconds.
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Alıcının STA MAC adresi (mac_oku çıktısı) / receiver's STA MAC address (from the MAC sketch)
6uint8_t aliciMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};
7
8const int BUTON_PIN = 4;               // buton GPIO 4 ile GND arasında / button between GPIO 4 and GND
9
10// İki kartta da birebir aynı olmalı / must be identical on both boards
11typedef struct __attribute__((packed)) {
12  uint8_t  id;                         // gönderen kart numarası / sender board number
13  uint32_t sayac;                      // paket sayacı / packet counter
14  float    sicaklik;                   // °C
15  float    nem;                        // % bağıl nem / % relative humidity
16  bool     buton;                      // buton basılı mı / is the button pressed
17  char     mesaj[16];                  // kısa metin, String değil / short text, not String
18} Paket;
19static_assert(sizeof(Paket) <= 250, "Paket 250 bayti asamaz / packet must not exceed 250 bytes");
20
21Paket paket;
22
23// Teslim sonucu: alıcı paketi aldıysa SUCCESS / delivery result: SUCCESS if the receiver got it
24#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
25void gonderimSonucu(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum) {   // çekirdek 3.3+ / core 3.3+
26#else
27void gonderimSonucu(const uint8_t *mac, esp_now_send_status_t durum) {                // çekirdek 2.x-3.2 / core 2.x-3.2
28#endif
29  Serial.println(durum == ESP_NOW_SEND_SUCCESS ? "Teslim edildi / delivered" : "Teslim edilemedi / failed");
30}
31
32void setup() {
33  Serial.begin(115200);
34  pinMode(BUTON_PIN, INPUT_PULLUP);
35  WiFi.mode(WIFI_STA);                 // önce Wi-Fi, sonra ESP-NOW / Wi-Fi first, then ESP-NOW
36
37  if (esp_now_init() != ESP_OK) {
38    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
39    return;
40  }
41  esp_now_register_send_cb(gonderimSonucu);
42
43  esp_now_peer_info_t es = {};         // tüm alanları sıfırla / zero every field
44  memcpy(es.peer_addr, aliciMac, 6);
45  es.channel = 0;                      // 0 = şu anki kanal / 0 = current channel
46  es.ifidx = WIFI_IF_STA;
47  es.encrypt = false;
48  if (esp_now_add_peer(&es) != ESP_OK) {
49    Serial.println("Es eklenemedi / failed to add peer");
50    return;
51  }
52  paket.id = 1;
53}
54
55void loop() {
56  paket.sayac++;
57  paket.sicaklik = 20.0 + random(0, 100) / 10.0;   // deneme değeri / test value
58  paket.nem = 40.0 + random(0, 200) / 10.0;
59  paket.buton = (digitalRead(BUTON_PIN) == LOW);
60  snprintf(paket.mesaj, sizeof(paket.mesaj), "Selam %lu", (unsigned long)paket.sayac);
61
62  esp_err_t sonuc = esp_now_send(aliciMac, (uint8_t *)&paket, sizeof(paket));
63  if (sonuc != ESP_OK) {
64    Serial.printf("Gonderme hatasi / send error: %s\n", esp_err_to_name(sonuc));
65  }
66  delay(2000);
67}

The sketch first starts Wi-Fi in WIFI_STA mode; ESP-NOW can't work without the Wi-Fi radio, so esp_now_init() must always come after the WiFi.mode() line. Then the receiver is added to the list as a peer. Clearing the esp_now_peer_info_t struct with = {} is important; random leftover values in uncleared fields cause the "Peer interface is invalid" error. channel = 0 means "use the board's current channel". esp_now_send() puts the packet in a queue and returns immediately; ESP_OK only means the packet was accepted for sending. The real result arrives a few milliseconds later in the gonderimSonucu() function: ESP_NOW_SEND_SUCCESS means the receiver acknowledged the packet at the hardware level. The #if block at the top picks the right form of the send callback, which depends on the core version; we explain this in the core 3.x section.

ESP-NOW receiver sketch

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Vericidekiyle birebir aynı yapı / exactly the same struct as the sender
6typedef struct __attribute__((packed)) {
7  uint8_t  id;
8  uint32_t sayac;
9  float    sicaklik;
10  float    nem;
11  bool     buton;
12  char     mesaj[16];
13} Paket;
14
15Paket gelen;
16volatile bool yeniPaket = false;
17
18// Wi-Fi görevinde çalışır: kısa tut / runs in the Wi-Fi task: keep it short
19#if ESP_IDF_VERSION_MAJOR >= 5
20void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
21#else
22void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
23#endif
24  if (uzunluk != sizeof(Paket)) return;  // farklı boyut: başka bir paket / wrong size: some other packet
25  memcpy(&gelen, veri, sizeof(Paket));   // veriyi hemen kopyala / copy the data right away
26  yeniPaket = true;
27}
28
29void setup() {
30  Serial.begin(115200);
31  WiFi.mode(WIFI_STA);
32
33  if (esp_now_init() != ESP_OK) {
34    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
35    return;
36  }
37  esp_now_register_recv_cb(veriGeldi);
38  Serial.println("Alici hazir / receiver ready");
39}
40
41void loop() {
42  if (yeniPaket) {
43    yeniPaket = false;
44    Paket p = gelen;                     // kopya üzerinde çalış / work on a copy
45    p.mesaj[sizeof(p.mesaj) - 1] = '\0'; // metin her zaman sonlansın / always terminate the text
46    Serial.printf("id=%u sayac=%lu sicaklik=%.1f nem=%.1f buton=%d mesaj=%s\n",
47                  (unsigned)p.id, (unsigned long)p.sayac, p.sicaklik, p.nem, (int)p.buton, p.mesaj);
48  }
49}

On the receiver, packets arrive in the veriGeldi() function. This function doesn't run in your loop() but in Wi-Fi's high-priority task, so you shouldn't use Serial.print, delay or long calculations inside it. The right approach is to check that the packet size matches the expected struct, copy the data into your own variable with memcpy and tell loop() through a volatile flag. On core 3.x the bilgi parameter also tells you who sent the packet (bilgi->src_addr) and the signal strength (bilgi->rx_ctrl->rssi, in dBm); this information is only valid inside the function, so copy it if you need it later. The receiver doesn't need to add the sender as a peer: unencrypted unicast packets and broadcasts are accepted from boards that aren't on the list.

"Delivered" means the packet reached the receiver's Wi-Fi hardware, not that the program on the receiver processed it. If the other board is powered and on the same channel, the ACK comes back even if it never started ESP-NOW. For important commands, use a separate software-level acknowledgement message and add a counter to your packets: if the receiver sees the counter jump, it knows how many packets were lost, and if the same number arrives twice, it ignores the duplicate.

Two-way communication: two ESP32 boards controlling each other

In two-way communication every board is both a sender and a receiver. You'll upload the sketch below to both boards; the only difference is that on each board you put the other board's address in the karsiMac array. When you press the button on one board, the LED on the other board turns on or off, and the board that changed its LED reports back with an acknowledgement message. The wiring is the same on both boards:

PartESP32 pinNote
ButtonGPIO 4 and GNDUses the internal pull-up, no extra resistor needed
LEDGPIO 2Onboard blue LED on most DevKits; use a 220 Ω resistor for an external LED
PowerUSBThe boards can be powered from separate USB ports or adapters
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Bu kodu iki karta da yükle; yalnız karşı kartın MAC adresini değiştir
6// Upload to both boards; change only the other board's MAC address
7uint8_t karsiMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};
8
9const int BUTON_PIN = 4;               // buton GPIO 4 ile GND arasında / button between GPIO 4 and GND
10const int LED_PIN = 2;                 // dahili LED / onboard LED
11
12typedef struct __attribute__((packed)) {
13  uint8_t tur;                         // 1 = komut, 2 = onay / 1 = command, 2 = acknowledgement
14  bool    ledAcik;                     // istenen ya da uygulanan durum / requested or applied state
15} Mesaj;
16
17volatile bool komutGeldi = false;
18volatile bool onayGeldi = false;
19volatile bool gelenLed = false;
20bool karsiLed = false;                 // karşı karttaki LED için son isteğimiz / our last request for the other LED
21int sonButon = HIGH;
22
23#if ESP_IDF_VERSION_MAJOR >= 5
24void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
25#else
26void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
27#endif
28  if (uzunluk != sizeof(Mesaj)) return;
29  Mesaj m;
30  memcpy(&m, veri, sizeof(m));
31  if (m.tur == 1) {
32    gelenLed = m.ledAcik;
33    komutGeldi = true;
34  } else if (m.tur == 2) {
35    onayGeldi = true;
36  }
37}
38
39void gonder(uint8_t tur, bool ledAcik) {
40  Mesaj m = {tur, ledAcik};
41  esp_now_send(karsiMac, (uint8_t *)&m, sizeof(m));
42}
43
44void setup() {
45  Serial.begin(115200);
46  pinMode(BUTON_PIN, INPUT_PULLUP);
47  pinMode(LED_PIN, OUTPUT);
48  WiFi.mode(WIFI_STA);
49
50  if (esp_now_init() != ESP_OK) {
51    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
52    return;
53  }
54  esp_now_register_recv_cb(veriGeldi);
55
56  esp_now_peer_info_t es = {};
57  memcpy(es.peer_addr, karsiMac, 6);
58  es.ifidx = WIFI_IF_STA;              // kanal 0, şifresiz / channel 0, unencrypted
59  esp_now_add_peer(&es);
60}
61
62void loop() {
63  // 1) Butona basılınca karşı kartın LED'ini değiştir / on press, toggle the other board's LED
64  int buton = digitalRead(BUTON_PIN);
65  if (buton != sonButon) {
66    delay(30);                         // kontak sıçramasını bekle / wait out contact bounce
67    buton = digitalRead(BUTON_PIN);
68    if (sonButon == HIGH && buton == LOW) {
69      karsiLed = !karsiLed;
70      gonder(1, karsiLed);
71      Serial.println(karsiLed ? "Komut: AC / command: ON" : "Komut: KAPAT / command: OFF");
72    }
73    sonButon = buton;
74  }
75
76  // 2) Gelen komutu uygula ve onay gönder / apply an incoming command and reply
77  if (komutGeldi) {
78    komutGeldi = false;
79    digitalWrite(LED_PIN, gelenLed ? HIGH : LOW);
80    gonder(2, gelenLed);
81  }
82
83  // 3) Karşı karttan gelen onay / acknowledgement from the other board
84  if (onayGeldi) {
85    onayGeldi = false;
86    Serial.println("Karsi kart uyguladi / the other board applied it");
87  }
88}

The tur field in the message struct says what the packet is: 1 is a command, 2 is an acknowledgement. The board whose button is pressed sends the state it wants for the other board's LED; the board that receives the command sets its LED and sends the same state back with type 2. This way the sender gets both the hardware ACK and the confirmation that "the LED really changed". The incoming command is again passed to loop() with volatile flags, and digitalWrite and the reply are done there. This is safer than calling esp_now_send() from inside the callback. The 30 ms wait on the button filters out contact bounce. Connect a relay instead of the LED and the same code becomes a wireless light switch; for relay wiring see What Is a Relay Module?.

Multiple devices: the 20-peer limit and broadcast

ESP-NOW supports all three layouts: one sender to many receivers (a remote to five relay boards), many senders to one receiver (five sensors to a single gateway) and networks where everyone talks to everyone. A board's peer list can hold at most 20 entries, and by default only 7 of them can be encrypted. This limit only applies to the boards you send to. A gateway doesn't have to add sensors that send it unencrypted packets, so it can listen to more than 20 sensors. If the list is full, esp_now_add_peer() returns ESP_ERR_ESPNOW_FULL; then you can remove peers you no longer need with esp_now_del_peer() and add new ones. The sketch below adds three receivers and the broadcast address to the list, then sends both one by one and as a broadcast:

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3
4// Üç alıcının STA MAC adresleri / STA MAC addresses of three receivers
5uint8_t alicilar[3][6] = {
6  {0x24, 0x6F, 0x28, 0x11, 0x11, 0x11},
7  {0x24, 0x6F, 0x28, 0x22, 0x22, 0x22},
8  {0x24, 0x6F, 0x28, 0x33, 0x33, 0x33}
9};
10uint8_t yayinMac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};   // yayın adresi / broadcast address
11
12typedef struct __attribute__((packed)) {
13  uint8_t  hedef;                      // 0 = herkes, 1-3 = tek alıcı / 0 = everyone, 1-3 = one receiver
14  uint32_t sayac;
15} Komut;
16
17Komut komut;
18
19bool esEkle(const uint8_t *mac) {
20  esp_now_peer_info_t es = {};
21  memcpy(es.peer_addr, mac, 6);
22  es.ifidx = WIFI_IF_STA;
23  return esp_now_add_peer(&es) == ESP_OK;
24}
25
26void setup() {
27  Serial.begin(115200);
28  WiFi.mode(WIFI_STA);
29  if (esp_now_init() != ESP_OK) {
30    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
31    return;
32  }
33  for (int i = 0; i < 3; i++) esEkle(alicilar[i]);
34  esEkle(yayinMac);                    // yayın da eş olarak eklenmeli / broadcast must be added as a peer too
35
36  esp_now_peer_num_t sayi;
37  esp_now_get_peer_num(&sayi);
38  Serial.printf("Kayitli es / registered peers: %d / 20\n", sayi.total_num);
39}
40
41void loop() {
42  komut.sayac++;
43
44  // 1) Tek tek: her alıcıya ayrı paket ve ayrı teslim onayı / one by one: separate packet and ACK each
45  for (int i = 0; i < 3; i++) {
46    komut.hedef = i + 1;
47    esp_now_send(alicilar[i], (uint8_t *)&komut, sizeof(komut));
48    delay(10);
49  }
50  delay(2000);
51
52  // 2) Yayın: tek paket, aynı kanaldaki herkes alır, onay yok / broadcast: one packet, everyone on the channel, no ACK
53  komut.hedef = 0;
54  esp_now_send(yayinMac, (uint8_t *)&komut, sizeof(komut));
55  delay(2000);
56}

The broadcast address FF:FF:FF:FF:FF:FF must also be added as a peer before you send to it. A single broadcast packet reaches every ESP-NOW receiver on the same channel; you don't need to know the receivers' MAC addresses, which makes it the easiest method to set up. The price is that broadcasts have no delivery acknowledgement, the send result always looks successful, and broadcast packets can't be encrypted. With one-by-one sending, each packet gets its own ACK. If you pass NULL as the address to esp_now_send(), the packet is sent separately to every peer on the list; if the broadcast address is on the list too, the packet also goes out once as a broadcast. A practical approach for many projects is to let the boards find each other with a broadcast on first boot, then learn their MAC addresses and switch to acknowledged unicast.

ESP-NOW range and speed

MetricTypical valueNote
Indoor range20–40 m (1–3 walls)Reinforced concrete walls and metal objects weaken the signal the most
Open-space range100–200 mWith the onboard PCB antenna; increases when the boards are placed higher above the ground
LR (Long Range) modeUp to 1 kmEspressif's line-of-sight figure; the rate drops to 0.5 or 0.25 Mbps
Default data rate1 MbpsA higher rate means a shorter range
Airtime of a 250-byte packetAbout 2.5 msAbout 3 ms per packet including ACK and waiting times
Theoretical packet rate~300 packets per second (~70 kB/s)With 250-byte packets; lower in practice
Command latencyA few millisecondsNo connection setup or server needed

These values vary a lot with the environment, but the maths is simple. At the default 1 Mbps, each byte stays in the air for 8 microseconds; 250 bytes of data with headers and the sync preamble take about 2.5 ms. With the receiver's ACK and the time spent waiting for a free channel, you need about 3 ms per packet, so at most around 300 packets can be sent per second. That's more than enough for sensor data and commands, but ESP-NOW isn't suitable for streaming audio or video. To increase range, place the boards high above the ground, don't cover the antenna with a metal box, batteries or cables, use modules with an external antenna (such as the ESP32-WROOM-32U) and enable Espressif's Long Range (LR) mode on both sides with esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR). LR only works between two ESP32 boards; an interface set to LR only can't connect to normal routers, and the ESP8266 and ESP32-C2 don't support this mode. When testing range on core 3.x, print bilgi->rx_ctrl->rssi on the receiver: −50 dBm is a very strong signal, −80 dBm is close to the limit, and packet loss starts around −90 dBm.

Changes in ESP32 Arduino core 3.x

Most ESP-NOW examples on the internet were written for ESP32 Arduino core 2.x. If you installed version 3.x from the Boards Manager, that code won't compile, because Espressif changed the form of the callback functions with ESP-IDF 5. The changes came in two steps: core 3.0 changed the receive callback, and core 3.3, based on ESP-IDF 5.5, changed the send callback. You can see which version you have next to the esp32 package under Tools > Board > Boards Manager.

TopicCore 2.xCore 3.0–3.2Core 3.3 and later
Receive callback(const uint8_t *mac, const uint8_t *veri, int uzunluk)(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk)Same as 3.0
Sender addressmacbilgi->src_addrbilgi->src_addr
Send callback(const uint8_t *mac, esp_now_send_status_t durum)Same as 2.x(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum)
Largest packet250 bytes250 bytes; 1470 bytes with v2 on 3.21470 bytes with v2
Setting the channelesp_wifi_set_channel()Also WiFi.setChannel()Same as 3.0
Ready-made class libraryNoneESP32_NOW.h (ESP_NOW_Peer, ESP_NOW_Serial)ESP32_NOW.h with v2 support

When you compile old code on 3.x, you'll see the error invalid conversion from 'void (*)(const uint8_t*, const uint8_t*, int)' ... to 'esp_now_recv_cb_t' for the receive callback. The fix is to make the function's first parameter const esp_now_recv_info_t * and read the sender's address from bilgi->src_addr. When you move to core 3.3, a similar error appears, this time for esp_now_send_cb_t; the send callback's first parameter must be const esp_now_send_info_t *, and the receiver's address is in bilgi->des_addr. The sketches in this guide use the ESP_IDF_VERSION macro to pick the right form themselves, so they compile unchanged on 2.x, 3.0–3.2 and 3.3+. Core 3.x also introduced a class-based library called ESP32_NOW.h: you write onReceive() and onSent() in a class derived from ESP_NOW_Peer, and the ESP_NOW_Serial class lets you use an ESP-NOW link just like Serial. You'll find its examples under ESP_NOW in the File > Examples menu. We use the esp_now.h functions directly because they stay compatible with the ESP8266 and with older code.

ESP-NOW and Wi-Fi at the same time: matching the channel

The ESP32 has a single 2.4 GHz radio, and that radio can only work on one channel at a time. Once the board connects to a router, the router, not you, decides the channel; if the router is on channel 6, ESP-NOW also runs on channel 6. So every sensor that sends packets to a gateway connected to Wi-Fi must also switch to the router's channel. If the channels don't match, the sender prints "failed"; if you added the peer with a non-zero channel, the Serial Monitor also shows the error "ESPNOW: Peer channel is not equal to the home channel, send fail!". Follow these rules for a trouble-free setup:

  1. The gateway connects to Wi-Fi first, starts ESP-NOW after that and prints the router's channel to the Serial Monitor with WiFi.channel().
  2. The sensors don't connect to the router; they find the channel by scanning for the network name (WiFi.scanNetworks() and WiFi.channel(i)) or use a fixed channel in the code. They don't need the Wi-Fi password for this.
  3. Set your router's channel from "Auto" to a fixed channel (1, 6 or 11). In auto mode, if the router changes channel, the sensors lose the gateway.
  4. Turn off power saving on the gateway with WiFi.setSleep(false). By default, an ESP32 connected to a router puts its radio to sleep at intervals and can miss ESP-NOW packets arriving in that time.
  5. Enter the gateway's STA MAC address on the sensors; the gateway sketch below prints this address to the Serial Monitor at startup.

The gateway sketch below connects to your home network, stores the readings from up to five sensors and shows them on a simple web page you can open from a phone on the same network. Replace the network name and password with your own, and after uploading, open the IP address shown in the Serial Monitor in your phone's browser:

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <WebServer.h>
3#include <esp_now.h>
4#include <esp_idf_version.h>
5
6const char *ssid = "AgAdi";            // Wi-Fi adın / your Wi-Fi name
7const char *sifre = "Sifre123";        // Wi-Fi şifren / your Wi-Fi password
8
9// Sensör kodundakiyle birebir aynı / identical to the sensor sketch
10typedef struct __attribute__((packed)) {
11  uint8_t  id;                         // sensör numarası 1-5 / sensor number 1-5
12  uint32_t sayac;
13  float    sicaklik;
14  float    nem;
15  float    pil;                        // pil gerilimi (V) / battery voltage (V)
16} SensorPaketi;
17
18const int SENSOR_SAYISI = 5;
19SensorPaketi sonVeri[SENSOR_SAYISI + 1];       // 1..5 kullanılır / indexes 1..5 are used
20unsigned long sonZaman[SENSOR_SAYISI + 1];     // son paketin zamanı / time of the last packet
21QueueHandle_t kuyruk;
22WebServer server(80);
23
24#if ESP_IDF_VERSION_MAJOR >= 5
25void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
26#else
27void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
28#endif
29  if (uzunluk != sizeof(SensorPaketi)) return;
30  SensorPaketi p;
31  memcpy(&p, veri, sizeof(p));
32  xQueueSend(kuyruk, &p, 0);           // kuyruğa at, Wi-Fi görevini bekletme / queue it, never block the Wi-Fi task
33}
34
35void anaSayfa() {
36  String html = "<meta name='viewport' content='width=device-width'>"
37                "<meta http-equiv='refresh' content='10'>"
38                "<h2>ESP-NOW sensorleri</h2><table border=1 cellpadding=6>"
39                "<tr><th>ID</th><th>Sicaklik</th><th>Nem</th><th>Pil</th><th>Son paket</th></tr>";
40  for (int i = 1; i <= SENSOR_SAYISI; i++) {
41    if (sonZaman[i] == 0) continue;    // bu sensörden veri yok / no data from this sensor yet
42    html += "<tr><td>" + String(i) + "</td><td>" + String(sonVeri[i].sicaklik, 1) +
43            " &deg;C</td><td>%" + String(sonVeri[i].nem, 0) + "</td><td>" + String(sonVeri[i].pil, 2) +
44            " V</td><td>" + String((millis() - sonZaman[i]) / 1000) + " sn once</td></tr>";
45  }
46  html += "</table>";
47  server.send(200, "text/html", html);
48}
49
50void setup() {
51  Serial.begin(115200);
52  kuyruk = xQueueCreate(10, sizeof(SensorPaketi));
53
54  WiFi.mode(WIFI_STA);
55  WiFi.begin(ssid, sifre);
56  while (WiFi.status() != WL_CONNECTED) delay(500);
57  WiFi.setSleep(false);                // güç tasarrufu kapalı: paket kaçmaz / power save off: no missed packets
58
59  Serial.print("IP: ");
60  Serial.println(WiFi.localIP());
61  Serial.printf("Kanal / channel: %d\n", (int)WiFi.channel());
62  Serial.print("Gecit MAC / gateway MAC: ");
63  Serial.println(WiFi.macAddress());   // sensörlere bu adresi yaz / put this address in the sensors
64
65  if (esp_now_init() != ESP_OK) {
66    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
67    return;
68  }
69  esp_now_register_recv_cb(veriGeldi);
70
71  server.on("/", anaSayfa);
72  server.begin();
73}
74
75void loop() {
76  SensorPaketi p;
77  while (xQueueReceive(kuyruk, &p, 0) == pdTRUE) {
78    if (p.id >= 1 && p.id <= SENSOR_SAYISI) {
79      sonVeri[p.id] = p;
80      sonZaman[p.id] = millis();
81      Serial.printf("Sensor %u: %.1f C  %%%.0f  %.2f V\n", (unsigned)p.id, p.sicaklik, p.nem, p.pil);
82    }
83  }
84  server.handleClient();
85}

In the receive callback, the gateway only drops the packet into a FreeRTOS queue and returns immediately. loop() empties the queue and stores each sensor's latest reading and arrival time, and the web server shows that table. The queue makes sure no packet is lost when several sensors send at the same moment; it's also the practical way to follow Espressif's advice not to do long work inside callbacks. The page refreshes itself every 10 seconds, and the "Son paket" (last packet) column shows right away whether a sensor has gone quiet (flat battery or out of range?). If the gateway can't connect to Wi-Fi at all, the problem isn't ESP-NOW; go through the checks in ESP32 won't connect to Wi-Fi. If you want to feed the data into a home automation system, you can use MQTT and Home Assistant instead of the web page.

Remote sensor example: DHT22, deep sleep and ESP-NOW

Now let's build the battery-powered sensor that sends data to the gateway. Each time it wakes up, the sensor measures temperature, humidity and battery voltage, sends a single ESP-NOW packet, waits for the delivery ACK and goes into deep sleep for 60 seconds. We use a DHT22 for temperature and humidity; you need to install Adafruit's DHT sensor library from the Library Manager. You'll find how the sensor works in our DHT11 temperature and humidity sensor guide; the wiring is the same for the DHT22, and in the code you only set the sensor type to DHT22.

ESP32 DevKit
ESP32 DevKit
DHT22 (AM2302)
DHT22 (AM2302)
18650 Li-Ion pil
18650 Li-Ion pil
TP4056 charging module
TP4056 charging module
ConnectionESP32 pinNote
DHT22 VCC (+)3V3Works from 3.3–5 V
DHT22 DATAGPIO 4On a bare sensor, add a 10 kΩ resistor between DATA and 3V3
DHT22 GND (−)GNDCommon ground
Battery +GPIO 34 (through the divider)Two 100 kΩ resistors; a 4.2 V battery reaches the pin as 2.1 V
Battery −GNDThe divider's lower resistor also goes to GND
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_wifi.h>
4#include <esp_idf_version.h>
5#include <DHT.h>
6
7const char *AG_ADI = "AgAdi";          // ağ geçidinin bağlandığı Wi-Fi (şifre gerekmez) / Wi-Fi the gateway uses (no password needed)
8uint8_t gecitMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};   // ağ geçidinin STA MAC adresi / gateway STA MAC
9const uint8_t SENSOR_ID = 1;           // her sensöre farklı numara / a different number for each sensor
10const uint64_t UYKU_US = 60ULL * 1000000ULL;   // 60 saniye uyku / sleep for 60 seconds
11
12#define DHT_PIN 4
13#define PIL_PIN 34                     // ADC1 pini, 100k + 100k bölücü / ADC1 pin, 100k + 100k divider
14DHT dht(DHT_PIN, DHT22);
15
16typedef struct __attribute__((packed)) {
17  uint8_t  id;
18  uint32_t sayac;
19  float    sicaklik;
20  float    nem;
21  float    pil;
22} SensorPaketi;
23
24RTC_DATA_ATTR uint8_t kanal = 0;       // derin uykuda silinmez / kept during deep sleep
25RTC_DATA_ATTR uint32_t paketSayaci = 0;
26volatile bool sonucGeldi = false;
27volatile bool teslimEdildi = false;
28
29#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
30void gonderimSonucu(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum) {   // çekirdek 3.3+ / core 3.3+
31#else
32void gonderimSonucu(const uint8_t *mac, esp_now_send_status_t durum) {                // çekirdek 2.x-3.2 / core 2.x-3.2
33#endif
34  teslimEdildi = (durum == ESP_NOW_SEND_SUCCESS);
35  sonucGeldi = true;
36}
37
38uint8_t kanalBul(const char *ag) {     // ağın kanalını tarayarak bul / find the network's channel by scanning
39  int n = WiFi.scanNetworks();
40  for (int i = 0; i < n; i++) {
41    if (WiFi.SSID(i) == ag) return WiFi.channel(i);
42  }
43  return 1;                            // bulunamazsa kanal 1 / channel 1 if not found
44}
45
46void uyu() {
47  esp_sleep_enable_timer_wakeup(UYKU_US);
48  esp_deep_sleep_start();
49}
50
51void setup() {
52  Serial.begin(115200);
53  dht.begin();
54  WiFi.mode(WIFI_STA);
55
56  if (kanal == 0) kanal = kanalBul(AG_ADI);   // yalnız ilk açılışta ya da hatadan sonra tara / scan only at first boot or after a failure
57  esp_wifi_set_promiscuous(true);
58  esp_wifi_set_channel(kanal, WIFI_SECOND_CHAN_NONE);
59  esp_wifi_set_promiscuous(false);
60
61  if (esp_now_init() != ESP_OK) uyu();
62  esp_now_register_send_cb(gonderimSonucu);
63
64  esp_now_peer_info_t es = {};
65  memcpy(es.peer_addr, gecitMac, 6);
66  es.channel = kanal;
67  es.ifidx = WIFI_IF_STA;
68  esp_now_add_peer(&es);
69
70  SensorPaketi p;
71  p.id = SENSOR_ID;
72  p.sayac = ++paketSayaci;
73  p.sicaklik = dht.readTemperature();
74  p.nem = dht.readHumidity();
75  p.pil = analogReadMilliVolts(PIL_PIN) * 2 / 1000.0;   // bölücü yarıya indirir / the divider halves it
76
77  esp_now_send(gecitMac, (uint8_t *)&p, sizeof(p));
78  unsigned long baslangic = millis();
79  while (!sonucGeldi && millis() - baslangic < 200) delay(1);   // en fazla 200 ms bekle / wait up to 200 ms
80
81  Serial.printf("Kanal %u, %.1f C, %%%.0f, %.2f V, %s\n", (unsigned)kanal, p.sicaklik, p.nem, p.pil,
82                teslimEdildi ? "teslim edildi / delivered" : "teslim edilemedi / failed");
83  if (!teslimEdildi) kanal = 0;        // teslim edilemedi: sonraki uyanışta yeniden tara / not delivered: rescan next wake
84  Serial.flush();
85  uyu();
86}
87
88void loop() {
89}

The key to this sketch is the variables marked with RTC_DATA_ATTR. In deep sleep the ESP32's main memory is wiped, but these variables in RTC memory survive. The sensor only finds the channel by scanning on the first boot (which takes about 2 seconds) and uses that channel directly on later wake-ups; if a packet can't be delivered, it resets the channel variable and scans again on the next wake-up. So even if the router changes channel, the sensor recovers after one cycle. The battery voltage is read already calibrated with analogReadMilliVolts() and multiplied by 2 because the divider halves it; you can check the resistor values in the Voltage divider tool. GPIO 34 is an ADC1 pin; this matters because ADC2 pins can't take analog readings while the Wi-Fi radio is on. For the details of sleep modes, see our ESP32 Deep Sleep guide.

Let's roughly estimate the battery life. A wake-up without a scan (boot, measurement and sending) takes about 0.3 seconds and draws about 100 mA on average, which comes to roughly 0.008 mAh per wake-up. Waking once a minute gives 1440 wake-ups a day, for a total of about 12 mAh. A bare ESP32 module draws around 10 µA in deep sleep; together with the DHT22's standby current and the divider resistors, sleep consumption stays below 2 mAh a day. So the sensor uses about 14 mAh a day, and a 2000 mAh 18650 cell lasts 4–5 months. To run the numbers with your own values, use the Battery life calculator.

This estimate is for a bare module or low-power boards. On an ordinary ESP32 DevKit, the regulator, the USB-serial chip and the power LED draw a few mA even in sleep and cut battery life down to a few weeks. For long-lasting sensors, use a regulator with a low quiescent current (such as the HT7333), remove the power LED or choose a ready-made low-power board. Increasing the measurement interval from 60 seconds to 5 minutes more than triples the battery life. To charge the 18650 cell safely, you can use a protected TP4056 module.

ESP-NOW with the ESP8266

Wemos D1 Mini ESP8266 board
ESP8266 boards such as the Wemos D1 Mini also support ESP-NOW, but their Arduino library and function signatures differ from the ESP32.

ESP8266 boards can use ESP-NOW on the same network as ESP32 boards, but their Arduino library is different: you include espnow.h instead of esp_now.h and ESP8266WiFi.h instead of WiFi.h, and the functions come from the ESP8266 SDK rather than ESP-IDF. The main differences are:

TaskESP32 (esp_now.h)ESP8266 (espnow.h)
Initialiseesp_now_init() == ESP_OKesp_now_init() == 0
RoleNot neededesp_now_set_self_role(ESP_NOW_ROLE_COMBO)
Add a peeresp_now_add_peer(&es) with a structesp_now_add_peer(mac, ESP_NOW_ROLE_COMBO, channel, NULL, 0)
Receive callback(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk)(uint8_t *mac, uint8_t *data, uint8_t len)
Send callback(..., esp_now_send_status_t durum)(uint8_t *mac, uint8_t status); 0 means success
Largest packet250 bytes (1470 with v2)250 bytes

On the ESP8266, don't forget to add WiFi.disconnect() after WiFi.mode(WIFI_STA); at startup the ESP8266 tries to reconnect to the last router it used on its own, which changes the channel and can cause lost packets. If you define the data struct as packed on both sides, the ESP32 and ESP8266 read the same bytes the same way, since both are 32-bit little-endian processors. For the ESP8266's pins and setup, see our ESP8266 NodeMCU guide.

ESP-NOW with MicroPython and the Raspberry Pi Pico

If you program the ESP32 in MicroPython, there's a ready-made espnow module for ESP-NOW; it's included in the MicroPython builds for the ESP32 and ESP8266. The code below receives the 30-byte Paket struct sent by the Arduino sender example and decodes it with struct.unpack(). In the format string, < means little-endian with no padding; B is a 1-byte number, I a 4-byte unsigned number, f a float and 16s 16 bytes of text. The bool field from Arduino is read as 0 or 1 with B. Upload the code to the board with Thonny and enter the MAC address it prints into the aliciMac array of the Arduino sender:

Python
1import network
2import espnow
3import struct
4import binascii
5
6sta = network.WLAN(network.STA_IF)     # ESP-NOW istasyon arayüzünü kullanır / ESP-NOW uses the station interface
7sta.active(True)
8print("MAC:", binascii.hexlify(sta.config("mac"), ":").decode())   # bu adresi vericiye yaz / put it in the sender
9
10e = espnow.ESPNow()
11e.active(True)
12
13BICIM = "<BIffB16s"                    # Arduino'daki Paket yapısı / the Paket struct from Arduino
14BOYUT = struct.calcsize(BICIM)         # 30 bayt / 30 bytes
15
16while True:
17    mac, msg = e.recv()                # paket gelene kadar bekle / wait for a packet
18    if msg and len(msg) == BOYUT:
19        kimlik, sayac, sicaklik, nem, buton, mesaj = struct.unpack(BICIM, msg)
20        metin = mesaj.split(b"\x00")[0].decode()
21        print(binascii.hexlify(mac, ":").decode(), kimlik, sayac,
22              round(sicaklik, 1), round(nem, 1), bool(buton), metin)

Sending a packet from MicroPython takes two lines too: add the receiver with e.add_peer(mac) and send the same struct with e.send(mac, struct.pack(BICIM, 2, 1, 23.5, 45.0, 0, b"Selam")), so Arduino and MicroPython boards can work together on the same network. On a MicroPython board connected to Wi-Fi, turn off power saving with sta.config(pm=sta.PM_NONE) so you don't miss packets. If you're new to MicroPython, start with our MicroPython basics guide. The Raspberry Pi Pico family, however, has no ESP-NOW: the standard Pico has no wireless at all, and the CYW43439 chip on the Pico W and Pico 2 W only runs standard Wi-Fi and Bluetooth. To bring a Pico into an ESP-NOW network, connect a small ESP32-C3 to it over UART serial communication and use it as a bridge, or let the Pico W talk to the gateway over Wi-Fi; Wi-Fi on the Pico W is covered in Pico W Wi-Fi and web server. The same bridge approach works for the Arduino Uno.

Encrypting ESP-NOW packets

By default ESP-NOW packets are unencrypted; anyone on the same channel can capture them over the air and send fake packets that imitate your struct. Use encryption for projects like door locks or garage doors. ESP-NOW uses CCMP, as in WPA2, and works with two keys: a 16-byte PMK that is the same on all devices (set with esp_now_set_pmk()) and a separate 16-byte LMK for each peer. When adding the peer, you set es.encrypt = true and copy the key into es.lmk; the other side must also add you as an encrypted peer with the same LMK. To receive encrypted packets, the receiver must add the sender to its list. Broadcast packets can't be encrypted, and by default you can have at most 7 encrypted peers. Encryption alone doesn't stop a captured packet from being replayed later; adding an increasing counter to critical commands and rejecting old numbers on the receiver is a good extra safeguard.

ESP-NOW project ideas

  • Wireless doorbell: When the button on the ESP32 at the door is pressed, a buzzer sounds on the board inside; for battery life, use the button as the pin that wakes the board from deep sleep.
  • Multi-room temperature monitoring: Put a DHT22 or BME280 sensor in every room, collect the readings on one gateway and compare them on a web page.
  • Garden watering: A soil moisture sensor sends a "water now" command to the board next to the pump; for wiring see Soil Moisture Sensor.
  • Wireless robot remote: Send joystick values 20 times a second; for the mechanical side of the robot, see Building an Arduino Robot Car.
  • Garden alarm: When a PIR sensor in the garden detects motion, the gateway sends a notification via Telegram.
  • Synchronised light show: Switch the WS2812B strips on several boards to the same colour at once with a single broadcast packet.

Common ESP-NOW errors and fixes

Symptom / errorCauseFix
"ESPNOW: Peer interface is invalid", peer can't be addedesp_now_peer_info_t used without clearing itClear the struct with = {} or memset and set ifidx = WIFI_IF_STA
Constant "failed" messagesWrong MAC (AP address or a typo), receiver off or on another channelRead the receiver's STA MAC address again and check both boards' channels
Packets arrive but the values are garbageThe struct differs between the two sides or contains a StringDefine the struct as packed in the same order on both sides and use a char array for text
"invalid conversion ... to 'esp_now_recv_cb_t'"Code written for core 2.x is compiled on 3.xWrite the receive callback with const esp_now_recv_info_t *
"invalid conversion ... to 'esp_now_send_cb_t'"Code written for core 3.0–3.2 is compiled on 3.3+Write the send callback with const esp_now_send_info_t * or use the ESP_IDF_VERSION macro
"Peer channel is not equal to the home channel, send fail!"The channel of the board connected to Wi-Fi differs from the peer's channelAdd the peer with channel = 0 and move the sensors to the router's channel
The gateway connected to Wi-Fi misses some packetsWi-Fi power saving (modem sleep)Use WiFi.setSleep(false)
ESP_ERR_ESPNOW_NOT_INITesp_now_init() wasn't called, or was called before Wi-Fi startedFirst WiFi.mode(WIFI_STA), then esp_now_init()
ESP_ERR_ESPNOW_FULLThe peer list has reached 20 entriesRemove old peers with esp_now_del_peer() or use broadcast
ESP_ERR_ESPNOW_NO_MEMPackets are sent back to back before the send queue emptiesWait for the send result before sending the next packet
The MAC address shows 00:00:00:00:00:00On core 3.x, WiFi.macAddress() was called before Wi-Fi fully startedUse esp_wifi_get_mac() after WiFi.mode(WIFI_STA)
When troubleshooting, start with the simplest step: put the two boards side by side and send to the broadcast address first. If the broadcast packet arrives, the radio, channel and data struct are fine and the problem is the MAC address. If nothing arrives, choose Debug under Tools > Core Debug Level; the core's detailed logs will appear in the Serial Monitor. If the board keeps restarting, the problem is most likely the power supply; fixes for the current spike when the radio turns on are in our ESP32 Brownout and Guru Meditation guide.

ESP-NOW is the fastest way to make several ESP32 boards talk without a router, cables or extra modules: read the MAC address, define your data struct the same on both sides, add the peer and send. When you use it alongside Wi-Fi, remember the one rule: everyone must be on the same channel. In the BlueGrays app you can try every building block from this guide, including LED and button control with the ESP32, temperature measurement with the DHT22, Wi-Fi connection, a web server and deep sleep, step by step with circuit diagrams and ready-to-use code.

Frequently asked questions

Does ESP-NOW need a router or internet?

No. ESP-NOW uses the boards' Wi-Fi radio but doesn't join any network; the boards send packets directly to each other's MAC addresses. If you want to get data to the internet, you need one ESP32 acting as a gateway that is connected to both Wi-Fi and ESP-NOW.

What is the range of ESP-NOW?

With ESP32 boards that have an onboard antenna, you typically get 20–40 metres indoors depending on the walls and around 100–200 metres in open space. With external-antenna modules and LR (Long Range) mode enabled on both sides, Espressif says up to 1 kilometre is possible in line of sight.

How many devices can connect with ESP-NOW?

A board's peer list can hold up to 20 devices, and by default at most 7 of them can be encrypted. This limit is for the devices you send to: a gateway can listen to more than 20 sensors sending unencrypted packets, and a broadcast reaches every device on the same channel with a single packet.

Can ESP-NOW and Wi-Fi be used at the same time?

Yes, but because the ESP32's single radio works on one channel at a time, every ESP-NOW device must use the router's channel. Turn off power saving on the gateway with WiFi.setSleep(false), and on the sensors find the channel by scanning for the network name or set the router to a fixed channel.

Can an ESP32 and an ESP8266 communicate with ESP-NOW?

Yes. Both chips support ESP-NOW and can send packets to each other; only the library (espnow.h) and the function signatures are different on the ESP8266. Define the data struct as packed and identical on both sides, and keep packets under 250 bytes.

What is the maximum data size for ESP-NOW?

With ESP-NOW v1 a packet holds at most 250 bytes, and this limit applies to every board including the ESP8266. Boards running ESP-IDF 5.4 or later (Arduino core 3.2+) can send up to 1470 bytes with ESP-NOW v2, but boards using v1 can't receive packets longer than 250 bytes.

See all projects
LED Blink with ESP32
#103Beginner

LED Blink with ESP32

Teaches basic GPIO output usage with ESP32. We achieve a blinking effect by turning an LED on and off from a digital pin. ESP32 version of the classic blink project on Arduino UNO; the basic blink commands in the Arduino environment are the same, but the ESP32's pin layout and 3.3 V GPIO levels differ from those of the Arduino UNO.

4.7(37)
99
ESP32 LED Control with Pushbutton (Toggle)
#105Beginner

ESP32 LED Control with Pushbutton (Toggle)

Each time the button is pressed the state of the LED changes: if it is on it turns off, if it is off it turns on. Classic toggle pattern. With debounce we clean up the mechanical noise of the pushbutton to ensure a single toggle with a single press.

4.6(39)
57
Two-Way Communication Between 2 Arduinos
#58Intermediate
Premium

Two-Way Communication Between 2 Arduinos

Two Arduinos are wired together and talk to each other: press the button on one and the light on the other comes on, press it again and it goes off. Both boards run the same program, so each one both

4.5(20)
26
ESP32 DHT22 Temperature and Humidity (with LCD Display)
#117Intermediate

ESP32 DHT22 Temperature and Humidity (with LCD Display)

This project measures ambient temperature and humidity using a DHT22 sensor and displays the data in real time on a 16x2 I2C LCD. Data is read from the sensor using the one-wire protocol via the Adafruit DHT library. This project serves as the foundation for IoT weather stations, smart home automation, or environmental monitoring systems in greenhouses. Communication with the DHT22 is established via the ESP32's GPIO 4 pin.

4.8(38)
56
ESP32 Wi-Fi STA Connection (with LCD Screen)
#127Advanced

ESP32 Wi-Fi STA Connection (with LCD Screen)

In this project, we connect the ESP32 to the home Wi-Fi network in Station (STA) mode in a secure, fault-tolerant manner. We store the SSID and password in a separate secrets.h file to prevent leaking to Git, set up an auto-reconnect mechanism, and display the RSSI signal level and IP address on an I2C LCD screen. The internal LED works as a status indicator. It is a production-grade connection manager.

4.4(21)
34
ESP32 Asynchronous Web Server (with IP Display on LCD)
#129Advanced

ESP32 Asynchronous Web Server (with IP Display on LCD)

In this project, we’re turning the ESP32 into an asynchronous web server that you can control from your phone or computer on the same Wi-Fi network. Thanks to the ESPAsyncWebServer library, the server can process multiple requests simultaneously (unlike the classic WebServer.h, which blocks on a single request). You’ll be able to open the ESP32’s IP address in a browser and click the ON/OFF buttons (the /on and /off routes) to turn the LED connected to GPIO 5 on and off.

4.8(16)
26
ESP32 Deep Sleep — Battery Saving (OLED + RTC RAM Counter)
#135Advanced
Premium

ESP32 Deep Sleep — Battery Saving (OLED + RTC RAM Counter)

The ESP32's Deep Sleep mode reduces current consumption to ~10 µA by shutting down the CPU and most peripherals, making it critical for battery-powered IoT devices. In this project, we use three different wake-up sources: a timer (15 seconds), an external pin (EXT0 — button GPIO 33), and touch (T0/GPIO 4). Variables marked with RTC_DATA_ATTR are preserved during deep sleep—the boot counter, timer wake counter, and button wake counter are continuously tracked. On each wake-up, the SSD1306 OLED

4.3(10)
16
ESP32 Temperature Data Logger (DHT22 + DS1307 RTC + microSD + OLED)
#144Advanced
Premium

ESP32 Temperature Data Logger (DHT22 + DS1307 RTC + microSD + OLED)

Build a timestamped temperature logger with ESP32! Read DHT22 temperature/humidity every minute, get real-world time from DS1307 RTC, and log to microSD card as CSV. The OLED displays live temperature (large font), humidity percentage, timestamp, and log counter. The /temp_log.csv file on the SD card opens in Excel, Google Sheets, or Python pandas for analysis — perfect for greenhouse monitoring, home/room temperature tracking, laboratory data collection, food storage conditions, or beehiv

4.7(7)
12