Setup guide

ESP-NOW with ESP32: Complete Sender, Receiver and Application ACK

Send a counter between two classic ESP32 boards on one fixed channel. Set station MAC peers and distinguish local queueing, MAC delivery status and a matching application acknowledgement.

Reference guide · Updated 2026-10-10

ESP-NOW with ESP32: Complete Sender, Receiver and Application ACK guide illustration
Jump to a section

Before you start

Hardware
Two classic ESP32-WROOM DevKits, two USB data cables and Serial access to both. No router, GPIO wires or sensors required.
Software
Arduino-ESP32 3.3.2 is the explicit API review baseline, not a latest-release claim. Built-in WiFi, esp_now/esp_wifi and FreeRTOS from core 3.3.2.
Prerequisite
Complete the Arduino IDE Serial-only first upload; select ESP32 Dev Module for the stated classic WROOM board.
Expected result
Receiver prints a counter; sender reports a matching application ACK or a timeout. This is an unencrypted bench pair.
Review limits
Documentation/API review and local content/browser checks only. Sketch compilation, physical hardware and playback testing were not performed.

Use a compatible pair and one channel

This example is reviewed for two classic WROOM boards with native Wi-Fi using core 3.3.2. ESP-NOW is an Espressif Wi-Fi protocol, not BLE; a normal phone is not an ESP-NOW receiver. Do not infer compatibility merely because a board has ESP32 in its name. Check native radio support, firmware version and matching packet format when mixing families.

Both examples use STA mode without joining a router, channel 6, five-byte unencrypted unicast messages and the other board’s station MAC. Set the same legal channel on both. If you later join a router, its channel constrains the station radio; do not keep assuming a separate ESP-NOW channel.

Set peers and upload the two sketches

First upload each complete sketch with the zero MAC placeholder. It prints its own STA MAC and stops sending. Copy the receiver MAC into the sender PEER_MAC and the sender MAC into the receiver PEER_MAC, then upload both again. Use the station address printed by these sketches, not a Bluetooth or AP address. Open both Serial Monitors at 115200. No external signal wiring is involved.

Both sides add the other peer because both transmit: the receiver sends application ACKs back. A receiver of unencrypted unicast alone does not require a peer, but replying does. Initialization, channel and peer/callback registration results are checked. Older core examples use different callback signatures: the send callback here uses esp_now_send_info_t for the 3.3.2/IDF 5.5 baseline.

Wiring and matching Arduino code

Sender: counter and matching-ACK timeout

Power the stated DevKit from USB. No GPIO wiring or onboard LED is required.

esp-now-sender.ino
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <cstring>

constexpr uint8_t CHANNEL = 6;
uint8_t PEER_MAC[6] = {0, 0, 0, 0, 0, 0}; // Replace with OTHER board's printed STA MAC.
bool ready = false;
QueueHandle_t events;
struct Event { uint8_t kind; uint8_t bytes[5]; };
void pack(uint8_t* b, uint8_t kind, uint32_t seq) {
  b[0] = kind;
  for (int i = 0; i < 4; ++i) b[i + 1] = (seq >> (8 * i)) & 0xFF;
}
uint32_t sequence(const uint8_t* b) {
  uint32_t n = 0;
  for (int i = 0; i < 4; ++i) n |= uint32_t(b[i + 1]) << (8 * i);
  return n;
}
void onReceive(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  if (!info || len != 5 || memcmp(info->src_addr, PEER_MAC, 6) != 0) return;
  Event e{2, {0}}; memcpy(e.bytes, data, 5);
  xQueueSend(events, &e, 0); // Copy callback data; never keep info/data pointers.
}
void onSent(const esp_now_send_info_t* info, esp_now_send_status_t status) {
  Event e{uint8_t(status == ESP_NOW_SEND_SUCCESS ? 0 : 1), {0}};
  xQueueSend(events, &e, 0); // No Serial or retry work in the Wi-Fi callback.
}
bool beginLink() {
  events = xQueueCreate(12, sizeof(Event));
  if (!events || !WiFi.mode(WIFI_STA)) return false;
  WiFi.setAutoReconnect(false);
  WiFi.disconnect(false);
  Serial.print("This board STA MAC: "); Serial.println(WiFi.macAddress());
  uint8_t sum = 0; for (uint8_t b : PEER_MAC) sum |= b;
  if (sum == 0) { Serial.println("Set PEER_MAC to the other board and upload again."); return false; }
  if (esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK ||
      esp_now_init() != ESP_OK) return false;
  esp_now_peer_info_t peer{};
  memcpy(peer.peer_addr, PEER_MAC, 6);
  peer.channel = CHANNEL; peer.ifidx = WIFI_IF_STA; peer.encrypt = false;
  return esp_now_add_peer(&peer) == ESP_OK &&
         esp_now_register_recv_cb(onReceive) == ESP_OK &&
         esp_now_register_send_cb(onSent) == ESP_OK;
}
void setup() {
  Serial.begin(115200);
  ready = beginLink();
  Serial.println(ready ? "ESP-NOW ready on channel 6" : "Link not ready; check setup and reset.");
}

uint32_t seq = 0, sentAt = 0, lastStart = 0;
bool waitingAck = false, txBusy = false;
void loop() {
  if (!ready) { delay(10); return; }
  Event e;
  while (xQueueReceive(events, &e, 0) == pdTRUE) {
    if (e.kind < 2) {
      txBusy = false;
      Serial.println(e.kind == 0 ? "MAC delivery success; not an application ACK" : "MAC delivery failed");
    } else if (e.bytes[0] == 2 && waitingAck && sequence(e.bytes) == seq) {
      waitingAck = false;
      Serial.printf("Application ACK for sequence %lu\n", (unsigned long)seq);
    }
  }
  const uint32_t now = millis();
  if (waitingAck && now - sentAt >= 1000) {
    waitingAck = false;
    Serial.println("No matching application ACK within 1 second.");
  }
  if (txBusy && now - sentAt >= 2000) {
    ready = false; // Do not overlap sends if the completion event was lost.
    Serial.println("Send completion missing; stop and inspect/reset the pair.");
    return;
  }
  if (!waitingAck && !txBusy && now - lastStart >= 5000) {
    lastStart = now;
    uint8_t data[5]; pack(data, 1, ++seq);
    const esp_err_t result = esp_now_send(PEER_MAC, data, sizeof(data));
    if (result == ESP_OK) {
      sentAt = now; waitingAck = true; txBusy = true;
      Serial.printf("Queued sequence %lu\n", (unsigned long)seq);
    } else Serial.printf("Send rejected locally: %d\n", int(result));
  }
  delay(10);
}

Receiver: parse and acknowledge the counter

Power the stated DevKit from USB. No GPIO wiring or onboard LED is required.

esp-now-receiver.ino
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <cstring>

constexpr uint8_t CHANNEL = 6;
uint8_t PEER_MAC[6] = {0, 0, 0, 0, 0, 0}; // Replace with OTHER board's printed STA MAC.
bool ready = false;
QueueHandle_t events;
struct Event { uint8_t kind; uint8_t bytes[5]; };
void pack(uint8_t* b, uint8_t kind, uint32_t seq) {
  b[0] = kind;
  for (int i = 0; i < 4; ++i) b[i + 1] = (seq >> (8 * i)) & 0xFF;
}
uint32_t sequence(const uint8_t* b) {
  uint32_t n = 0;
  for (int i = 0; i < 4; ++i) n |= uint32_t(b[i + 1]) << (8 * i);
  return n;
}
void onReceive(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  if (!info || len != 5 || memcmp(info->src_addr, PEER_MAC, 6) != 0) return;
  Event e{2, {0}}; memcpy(e.bytes, data, 5);
  xQueueSend(events, &e, 0); // Copy callback data; never keep info/data pointers.
}
void onSent(const esp_now_send_info_t* info, esp_now_send_status_t status) {
  Event e{uint8_t(status == ESP_NOW_SEND_SUCCESS ? 0 : 1), {0}};
  xQueueSend(events, &e, 0); // No Serial or retry work in the Wi-Fi callback.
}
bool beginLink() {
  events = xQueueCreate(12, sizeof(Event));
  if (!events || !WiFi.mode(WIFI_STA)) return false;
  WiFi.setAutoReconnect(false);
  WiFi.disconnect(false);
  Serial.print("This board STA MAC: "); Serial.println(WiFi.macAddress());
  uint8_t sum = 0; for (uint8_t b : PEER_MAC) sum |= b;
  if (sum == 0) { Serial.println("Set PEER_MAC to the other board and upload again."); return false; }
  if (esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK ||
      esp_now_init() != ESP_OK) return false;
  esp_now_peer_info_t peer{};
  memcpy(peer.peer_addr, PEER_MAC, 6);
  peer.channel = CHANNEL; peer.ifidx = WIFI_IF_STA; peer.encrypt = false;
  return esp_now_add_peer(&peer) == ESP_OK &&
         esp_now_register_recv_cb(onReceive) == ESP_OK &&
         esp_now_register_send_cb(onSent) == ESP_OK;
}
void setup() {
  Serial.begin(115200);
  ready = beginLink();
  Serial.println(ready ? "ESP-NOW ready on channel 6" : "Link not ready; check setup and reset.");
}

void loop() {
  if (!ready) { delay(10); return; }
  Event e;
  while (xQueueReceive(events, &e, 0) == pdTRUE) {
    if (e.kind < 2) {
      Serial.println(e.kind == 0 ? "ACK MAC delivery success" : "ACK MAC delivery failed");
    } else if (e.bytes[0] == 1) {
      const uint32_t seq = sequence(e.bytes);
      Serial.printf("Processed counter sequence %lu\n", (unsigned long)seq);
      uint8_t ack[5]; pack(ack, 2, seq);
      const esp_err_t result = esp_now_send(PEER_MAC, ack, sizeof(ack));
      if (result != ESP_OK) Serial.printf("ACK send rejected: %d\n", int(result));
    }
  }
  delay(10);
}

Three different meanings of success

SignalWhat it meansWhat it does not prove
esp_now_send returns ESP_OKThe local API accepted the packet for sendingThe other board received or processed it.
ESP_NOW_SEND_SUCCESS callbackMAC-layer delivery succeededApplication parsing or an actuator action succeeded.
ACK packet with matching sequenceThis demo receiver parsed the counter and reached its ACK codeAn arbitrary physical action worked, or the packet is authenticated.

The wire format is one type byte plus a four-byte little-endian sequence, avoiding C++ struct padding assumptions. Type 1 is a counter; type 2 is its ACK. Sender waits one second for a matching ACK and starts a new counter no more often than every five seconds. It does not retransmit lost counters or guarantee exactly-once processing.

Keep callback work short

Wi-Fi task callbacks copy data/results into queues with zero wait; loop prints, parses and sends ACKs. Received pointers are callback-local and are not retained. A full queue can lose events; the sender stops if send completion is absent for two seconds. This small demo is not a reliable-delivery library. A production command protocol needs bounded retries, duplicate suppression, session IDs across resets and appropriate authentication/encryption.

Expected result and troubleshooting

After five seconds the sender should report Queued sequence 1; the receiver should print Processed counter sequence 1 and the sender should report Application ACK for sequence 1. These are expected messages, not recorded hardware results. Unplug the receiver: local queue acceptance may still occur, MAC delivery may fail, and no matching ACK should arrive.

If nothing arrives, check both STA MACs, channel, board selection and initialization messages. If MAC delivery succeeds but ACK times out, inspect receiver parsing/queue logs and its return peer configuration. A source-MAC filter is not cryptographic authentication. Keep this encrypt=false example on a bench and send no secrets or safety-critical commands.

Next steps

Treat a matching application ACK as evidence of the receiver’s explicit processing step, not as proof of hardware action. Keep the channel, peer addresses and wire format explicit.

Technical references