ESP32 ESP-NOW Tutorial: Send Data Between Boards Without a Router

 

ESP32 ESP-NOW Tutorial: Send Data Between Boards Without a Router

You have two ESP32 boards and you want one to send a sensor reading to the other. No router, no broker, no pairing dance. That is what ESP-NOW is for.

You may also have pasted an ESP-NOW example from a few years ago and hit a compile error about esp_now_recv_info_t. This guide uses code that handles both the old and new Arduino core APIs. It also covers the parts most tutorials skip: payload limits, why "delivery success" can mislead you, and what to check when packets vanish.

Quick answer: what is ESP-NOW?

ESP-NOW is a connectionless wireless protocol from Espressif. One ESP32 sends a small packet directly to another using its MAC address. There is no access point and no handshake. It uses a vendor-specific action frame, and the default bit rate is 1 Mbps. Espressif

In practice it suits sensor nodes, remote controls and other small, frequent messages between ESP32-family boards.

ESP-NOW vs Wi-Fi vs BLE vs MQTT

ESP-NOWWi-Fi (HTTP/MQTT)BLE
Needs a routerNoYesNo
SetupMAC address onlySSID, password, serverServices and characteristics
PayloadSmall packetsLargeSmall
Talks to phones/internetNoYesPhones only
Best forBoard-to-board sensor dataCloud and dashboardsPhone apps

Use ESP-NOW when both ends are ESP32s and the data is small. Use Wi-Fi or MQTT when the data has to reach the internet. For phone connections, see the BLE tutorial.

Limits to know before you build

  • Payload size: v1.0 packets carry up to 250 bytes. v2.0 carries up to 1470 bytes, but needs ESP-IDF v5.4 or later. Check which core version you have before relying on the larger size. Esptool Documentation
  • Peers: The MicroPython docs list up to 20 registered peers. The limit on encrypted peers depends on the ESP-IDF version. Before v5.1 it was fixed at 6. micropythonEsptool Documentation
  • Board support: ESP-NOW is available across the ESP32 family, but confirm your specific chip and core version.

What you need

  • Two ESP32 boards (any mix of DevKit, S3 or C-series that your core supports)
  • Arduino IDE with the ESP32 board package installed
  • Two USB cables and two serial monitor windows. Open one IDE window per board so you can watch both.

Step 1: Find the receiver's MAC address

The sender needs the receiver's MAC address. Upload this to the receiver board:

cpp
#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);   // set the mode first, or some cores print zeros
  delay(500);
  Serial.println(WiFi.macAddress());
}

void loop() {}

Copy the address from the serial monitor, for example A4:CF:12:34:56:78. In code you'll write it as {0xA4, 0xCF, 0x12, 0x34, 0x56, 0x78}.

Step 2: The receiver sketch

Both sketches share one data structure. Keep the struct identical on both boards. If the sizes differ, the receiver reads garbage.

The callback below adapts to the core version. The 3.0.0 boundary comes from the documented core 3.x callback change.

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

typedef struct {
  uint8_t  nodeId;
  float    temperature;
  uint32_t counter;
} Packet;

// Core 3.x passes an info struct. Core 2.x passes the MAC directly.
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
void onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
  const uint8_t *mac = info->src_addr;
#else
void onReceive(const uint8_t *mac, const uint8_t *data, int len) {
#endif
  if (len != sizeof(Packet)) return;   // ignore packets of the wrong size

  Packet p;
  memcpy(&p, data, sizeof(p));

  char macStr[18];
  snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
           mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);

  Serial.printf("From %s | node %u | %.1f C | #%lu\n",
                macStr, p.nodeId, p.temperature, (unsigned long)p.counter);
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_recv_cb(onReceive);
}

void loop() {}

Start Wi-Fi before initializing ESP-NOW, which is why WiFi.mode() comes first. espressif

Step 3: The sender sketch

Put the receiver's MAC in receiverMac.

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

uint8_t receiverMac[] = {0xA4, 0xCF, 0x12, 0x34, 0x56, 0x78};  // <- your receiver

typedef struct {
  uint8_t  nodeId;
  float    temperature;
  uint32_t counter;
} Packet;

Packet packet;
uint32_t counter = 0;

// The send callback signature also differs between core versions.
// If this fails to compile, check your core version against the docs.
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 3, 0)
void onSent(const esp_now_send_info_t *info, esp_now_send_status_t status) {
#else
void onSent(const uint8_t *mac, esp_now_send_status_t status) {
#endif
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "MAC-layer OK" : "Send FAILED");
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_send_cb(onSent);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, receiverMac, 6);
  peer.channel = 0;        // 0 = use the current channel
  peer.encrypt = false;

  if (esp_now_add_peer(&peer) != ESP_OK) {
    Serial.println("Failed to add peer");
  }
}

void loop() {
  packet.nodeId = 1;
  packet.temperature = 20.0 + (random(0, 50) / 10.0);  // fake reading
  packet.counter = counter++;

  esp_err_t result = esp_now_send(receiverMac, (uint8_t *)&packet, sizeof(packet));
  if (result != ESP_OK) {
    Serial.printf("esp_now_send error: %s\n", esp_err_to_name(result));
  }
  delay(2000);
}

Upload the receiver first, then the sender. You should see a new line on the receiver every two seconds.

A note on testing: I could not run these sketches on hardware while writing this. The receive-callback change is well documented. I set the send-callback version boundary (3.3.0) from the current Espressif docs, so confirm it against your installed core. If the send callback doesn't compile, look at the signature in your core's esp_now.h and adjust the #if line.

Why "Send success" doesn't mean the message arrived

This trips up many people. Success only means the MAC layer received the data, and the application layer may still not get it. If a lost message would matter, build your own acknowledgement: espressif

  1. Have the receiver send a small reply packet.
  2. Have the sender retransmit if no reply arrives in time.
  3. Add a sequence number (like counter above) so the receiver can drop duplicates.

Espressif's docs suggest exactly this pattern. espressif

Troubleshooting: why isn't my ESP-NOW working?

"esp_now_recv_info_t does not name a type" or "invalid conversion from void". Your core version and your code disagree about the callback signature. Use the #if guards above. Espressif's core 3.0 migration introduced breaking changes, and older tutorial code stopped compiling. RNT Lab

Send always fails. The usual causes are a receiver that isn't running or doesn't exist, mismatched channels, or frames lost in transit. Check the MAC address first, then the channel. espressif

Works alone, fails once the sender joins your router. When an ESP32 connects to a Wi-Fi network, its radio moves to the router's channel. The receiver must be on that same channel. The easy fix is to have every node join the same router, or to set the channel explicitly on both ends. This is a common reason ESP-NOW "stops working" after adding Wi-Fi code from the Wi-Fi guide.

Garbled values. The struct differs between the two sketches, or you're mixing boards with different padding or endianness assumptions. Keep the struct identical, and check len in the callback.

Callback does heavy work. Keep the receive callback short: copy the data, set a flag, and process it in loop(). Long work in the callback can cause dropped packets.

Real-world use: battery-powered sensor nodes

ESP-NOW pairs well with deep sleep. A node wakes, reads a sensor, sends one packet, and sleeps again, without ever joining a network. That avoids the multi-second Wi-Fi connection that dominates battery use in a typical sensor loop. See the Deep Sleep guide for wake-up sources and power modes.

A common architecture is many sleeping ESP-NOW nodes reporting to one always-on ESP32 gateway. That gateway also joins Wi-Fi and forwards readings over MQTT.

Going further

  • More than 250 bytes: ESP-NOW v2.0 raises the limit, but requires a recent ESP-IDF version. Check your core's IDF version first.
  • Encryption: ESP-NOW supports encrypted peers, but the number of encrypted peers is limited and version-dependent, as noted above.
  • Power saving: The IDF exposes settings such as an RX wake window for ESP-NOW during sleep. These are advanced options, so read the current API docs before using them. Espressif

FAQ

Does ESP-NOW need Wi-Fi?
It needs the Wi-Fi radio started, but not a router or internet connection.

How much data can I send in one packet?
250 bytes on v1.0, or up to 1470 bytes on v2.0 with ESP-IDF v5.4 or later. Esptool Documentation

Can ESP-NOW work with an ESP8266?
Yes, some tutorials show ESP32 to ESP8266 communication. The callback and API details differ between the two chips. Microcontrollers Lab

How many boards can I connect?
Up to 20 registered peers, with a lower limit for encrypted peers. micropython

Can ESP-NOW and Wi-Fi run at the same time?
Yes, but both must use the same channel. See the troubleshooting section.

Why does my old ESP-NOW code not compile?
The callback signatures changed in Arduino core 3.x. Use the version-guarded sketches above.

Is ESP-NOW guaranteed delivery?
No. Add your own acknowledgement if you need it.

Final takeaway

ESP-NOW is the simplest way to move small readings between ESP32 boards. The real hurdles are version mismatches, channel conflicts and the difference between MAC-layer success and actual delivery. Get those right and it's reliable enough for most sensor projects.

Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.