Skip to content

ESP32 · Build guide

ESP-NOW on ESP32: Send Sensor Data Wirelessly Without Wi-Fi or a Router

A ground-up ESP-NOW tutorial: two ESP32 boards exchange IR and MQ135 sensor data directly by MAC address, with no router or internet. Includes core 2.x/3.x compatible code, link-loss detection and encryption.

Difficulty
Intermediate
Read time
18 min
Board
ESP32
Author
Manish Pal
Published
Jun 18, 2026
Views
0
ESP-NOW on ESP32: Send Sensor Data Wirelessly Without Wi-Fi or a Router

Introduction

Most ESP32 wireless projects begin with a Wi-Fi router, and that quietly adds a long list of dependencies: credentials in your code, a network that has to be up, an IP address that might change, and a few hundred milliseconds of variable delay. For a sensor that just needs to tell another board "something moved", that is a lot of machinery.

ESP-NOW removes it. It is a connectionless protocol from Espressif that lets ESP32 boards send small packets straight to each other, addressed by MAC address. There is no access point, no password, no DHCP, no TCP/IP stack and no internet. A board can power up and transmit within moments, which is also why ESP-NOW is popular for battery-powered nodes.

This tutorial builds a complete, working link from the ground up. A sender ESP32 reads an IR obstacle sensor and an MQ135 air-quality sensor and transmits a small packet every 300 ms. A receiver ESP32 shows the data on an OLED, sounds a siren when something is wrong, and tells you when the link goes quiet. The sensors are only the demo. The real goal is that you understand how ESP-NOW works, so you can reuse the pattern in anything else.

Along the way you will learn how to:

  • find a board's MAC address and use it to pair two boards;
  • design a packet structure that survives different compilers and boards;
  • write callbacks that are safe, because they run inside the Wi-Fi task;
  • detect lost packets and a dead link, which ESP-NOW does not do for you;
  • write code that compiles on both the older 2.x and the current 3.x Arduino-ESP32 cores;
  • add encryption, and understand what it does and doesn't protect.

What Is ESP-NOW?

ESP-NOW is a connectionless Wi-Fi communication protocol defined by Espressif. Your data is wrapped in a vendor-specific action frame, a standard 802.11 management-frame type that lets manufacturers define their own payloads, and is sent from one device straight to another. It uses the same 2.4 GHz radio and the same Wi-Fi driver as normal Wi-Fi, but it skips association with an access point and skips the TCP/IP stack entirely. Fewer layers means less setup time, less overhead and less power per message.

CharacteristicESP-NOW
AddressingThe 6-byte MAC address of the receiving board
Payload per packetUp to 250 bytes (v1.0). Newer ESP-IDF 5.4+ builds also support ESP-NOW v2.0 with up to 1,470 bytes, between devices that both support it
PeersUp to 20 registered peers, of which at most 6 can use encryption (default configuration)
EncryptionOptional, per peer, using CCMP with a 16-byte key. Not available for broadcast
TopologyOne-to-one, one-to-many, many-to-one, or broadcast
ConnectionNone. There is no handshake and no session
ChannelBoth boards must be on the same Wi-Fi channel
Delivery feedbackA send callback reports whether the receiving radio acknowledged a unicast frame

Two properties deserve emphasis because they shape how you design anything on top of ESP-NOW. First, there is no connection, so neither side automatically knows whether the other is alive. If the receiver loses power, the sender carries on transmitting into the void and nothing in the protocol complains. We will add our own link-loss detection for exactly that reason. Second, callbacks run in the Wi-Fi task, a high-priority task inside the radio driver. If you spend 300 ms in a callback, you are blocking the radio itself.

How it compares

ESP-NOWWi-Fi + routerBluetooth LELoRa
Needs infrastructureNoYes (router)NoGateway for networks
Setup effortLowMediumMediumMedium
Typical useSensors, remotes, board-to-boardInternet, dashboards, cloudPhones, wearablesVery long range, tiny data
RangeShort to mediumRouter coverageShortLong
Works with a phoneNoYesYesVia gateway

ESP-NOW is the right tool when both ends are ESP32 or ESP8266-family boards, the data is small, and you want fast, simple, router-free communication. It is the wrong tool when you need to reach a phone directly or the internet.

Image pending: Two ESP32 boards communicating directly over ESP-NOW by MAC address, with the router path crossed out

The Four Things ESP-NOW Needs

Before writing any code, here are the four building blocks every ESP-NOW program uses. If you remember nothing else from this article, remember these.

1. The Wi-Fi radio on, but not connected. Both boards call WiFi.mode(WIFI_STA). That powers up the radio and driver. They never join a network. ESP-NOW data can only be transmitted after Wi-Fi has started, so this always comes first.

2. ESP-NOW initialisation. esp_now_init() starts the protocol engine. Check its return value, because if it fails, every later call fails in confusing ways.

3. A registered peer (sender side). The sender registers the receiver with esp_now_add_peer(), giving the receiver's MAC address, a channel and an encryption flag. A receiver that only listens does not need to register anyone.

4. Callbacks. The sender registers a send callback that fires after each transmission with a success or failure status. The receiver registers a receive callback that fires whenever a packet arrives, passing the raw bytes and information about the sender.

The data itself is a plain byte array. The cleanest way to handle it is a C struct that is defined identically on both boards, copied into bytes when sending and copied back out when receiving.

Components

8 items
NameQtyLink
ESP32 DevKit V1 (one sender, one receiver)×2—
IR obstacle sensor module×1—
MQ135 gas sensor module×1—
10 kΩ resistors (voltage divider for the MQ135 output)×2—
SSD1306 OLED, 128×64, I2C×1—
Passive buzzer or small speaker (with a series resistor or transistor stage)×1—
Breadboards and jumper wires×1—
USB cables (power and serial monitor)×2—

Need these parts? Get this build sheet by email

Parts list with buy links, plus new builds as they're published. By subscribing you agree to our Privacy Policy. Unsubscribe anytime.

The sensors and display exist to give the link something meaningful to carry. Swap in any sensors you own, and the communication code stays the same.

Circuit Connections

Sender node

DevicePinESP32 GPIO
IR obstacle sensorOUTGPIO 32
MQ135 module (via divider)AOGPIO 35

Receiver node

DevicePinESP32 GPIO
SSD1306 OLEDSDAGPIO 19
SSD1306 OLEDSCLGPIO 21
Buzzer / speakerSignalGPIO 18

GPIO 35 on the DevKit V1 is input-only and sits on ADC1, which makes it a good analog input, and ADC1 keeps working while Wi-Fi is active (the ADC2 pins do not). The receiver's I2C bus is started manually with Wire.begin(19, 21), so you are not locked to the default pins.

⚠️ Warning

The MQ135 module needs about 5 V for its heater, and its AO output can swing above the ESP32's 3.3 V ADC limit. Feeding that straight into GPIO 35 can damage the pin. Put two 10 kΩ resistors in series between AO and ground and connect GPIO 35 to the middle point, which halves the voltage. The readings will scale down accordingly, so judge your alert level from your own measurements. The MQ135 also needs several minutes to warm up before its values settle, and a brand-new sensor benefits from a long first burn-in. Power the IR module from 3.3 V so its output never exceeds what the ESP32 can tolerate.

Image pending: Sender node: ESP32 DevKit V1 wired to the IR obstacle sensor (GPIO 32) and the MQ135 gas sensor (GPIO 35) through the divider
Image pending: Receiver node: ESP32 DevKit V1 wired to the SSD1306 OLED (SDA GPIO 19, SCL GPIO 21) and the buzzer (GPIO 18)

Step 1: Read the Receiver's MAC Address

A unicast ESP-NOW link is addressed by MAC. The sender needs the receiver's exact 6-byte address. Flash this to the receiver board and open Serial Monitor at 115200 baud. It prints the address once a second, so you will not miss it if you open the monitor late.

C++

You will see something like A8:42:E3:BA:C2:D8. Write it on a sticker and put it on the board, because you will want it again. In the sender sketch it becomes a byte array: {0xA8, 0x42, 0xE3, 0xBA, 0xC2, 0xD8}. Each board has its own MAC, so use the address of your receiver, not the example.

Step 2: Define the Shared Packet

Both boards must agree on the exact byte layout of the packet. Here is the structure used in this project. Copy it into both sketches unchanged, or put it in a small message.h file and include it from both.

C++

Three choices here are deliberate, and each prevents a real bug:

  • Fixed-width types (uint8_t, uint16_t, uint32_t) rather than bool and int. They are the same size on every board and compiler, so the layout cannot silently change if you later mix ESP32 variants or code from other sources.
  • packed. Without it, the compiler may insert invisible padding bytes between fields so each one starts at a convenient address. Padding is harmless within one program but changes sizeof, which would make two builds disagree on the packet length.
  • A sequence number. ESP-NOW has no concept of "the packet before this one". With a counter in every packet, the receiver can detect gaps and report real packet loss instead of guessing.

If the struct differs between boards, even by one field, memcpy will misalign every value and you will read garbage. The receiver also checks that the incoming length equals sizeof(Message) and ignores anything else, which cheaply filters out foreign packets.

Step 3: The Sender

The sender starts the radio, forces a known channel, initialises ESP-NOW, registers the receiver as a peer, then reads both sensors every 300 ms and sends one packet. It uses millis() for timing so loop() never stalls.

A note on callback signatures

Espressif changed the callback prototypes between ESP-IDF versions, and the Arduino-ESP32 core follows it. The receive callback gained an esp_now_recv_info_t argument in ESP-IDF 5.1 (core 3.x), and the send callback changed again in ESP-IDF 5.5 (core 3.3 and later), where it receives an esp_now_send_info_t instead of the MAC address. If you copy code written for one core into another, the compiler complains about an invalid conversion. The sketches below use ESP_IDF_VERSION checks so the same file compiles on the older 2.x cores and on current 3.x cores.

C++

Reading the sender's output

esp_now_send() returning ESP_OK only means the packet was queued. Whether it actually arrived is reported later by onDataSent, which for a unicast peer reflects whether the receiving radio acknowledged the frame at the link layer. That is why the sketch counts successes and failures separately. A steady stream of failures means the receiver is off, out of range, or on a different channel.

Step 4: The Receiver

The receiver's callback does as little as possible: check the length, copy the packet under a lock, set a flag. Everything slow happens in loop(): updating the OLED, counting lost packets, and running the siren.

Two details are worth understanding. The copy is wrapped in a critical section because the callback runs on the Wi-Fi task while loop() runs on another task, possibly on the other core. Even a seven-byte struct could otherwise be read half-updated. And the receiver tracks the time of the last packet, so it can show LINK LOST when nothing has arrived for two seconds, which is the monitoring ESP-NOW does not give you for free.

C++
Image pending: Receiver OLED showing SAFE with live IR and MQ135 values, packets received and packets lost

💡 Tip

If you see an error such as "invalid conversion from void (*)(const uint8_t*, ...)", the callback prototype does not match your core. The ESP_IDF_VERSION checks above handle ESP-IDF 4.4 (core 2.x), 5.1 to 5.4 (core 3.0 to 3.2) and 5.5+ (core 3.3 and later). If a future core changes the prototype again, copy the signature from the compiler error. The two keys to remember: the receive callback takes (info, data, len) from IDF 5.1 on, and the send callback takes an info struct from IDF 5.5 on.

What Happens on Every Cycle

Once both boards are powered, this sequence repeats every 300 ms:

  1. The sender reads its sensors and fills the packet, including the next sequence number.
  2. esp_now_send() queues the packet, addressed to the receiver's MAC.
  3. The radio transmits on the fixed channel. No router is involved.
  4. onDataSent fires on the sender with the link-layer acknowledgement result.
  5. onReceive fires on the receiver, which copies the bytes and sets a flag.
  6. loop() picks up the packet, counts any gap in the sequence, redraws the OLED and decides whether to sound the siren.

The receiver never polls the sender. It reacts to arrivals and watches the clock for silence. That event-driven shape is what makes ESP-NOW feel instant, and the lost-packet counter and link timeout are what make it trustworthy.

Testing the Link

Work through these checks in order. Each one isolates a single failure.

  1. Flash the receiver first and note its MAC. It prints it on boot, and the OLED should say WAITING.
  2. Put the MAC into the sender sketch, flash the sender, and open its Serial Monitor. You should see lines counting up and ok increasing.
  3. On the receiver the OLED should change to SAFE and the rx counter should climb about three times a second.
  4. Pass your hand in front of the IR sensor. The OLED should flip to ALERT and the siren should start, then stop when you remove your hand.
  5. Unplug the sender. After about two seconds the receiver should show LINK LOST. Plug it back in and it should recover on its own.
  6. Walk the sender away until lost starts rising. That tells you roughly where your usable range ends in your own building.

Step 6 is the most valuable. Published range figures are best-case numbers measured in the open, and your walls, metal enclosures and antenna orientation decide what you actually get.

Image pending: Serial Monitor on the receiver: sequence numbers climbing, sensor values and the running lost-packet count

Channels, Wi-Fi and Why Links Break

The radio can only listen on one channel at a time, and ESP-NOW frames are sent on whichever channel the radio is currently using. For two boards to talk, they must be on the same one. That is why the sketches pin both to channel 1 explicitly instead of trusting defaults.

The catch comes when one of your boards also joins a Wi-Fi router, for example to publish data online. A station that connects to a router is forced onto the router's channel, and a receiver that jumps channels will stop hearing a sender that did not. Two ways to handle it:

  • Make the board that talks to the router the receiver, read its channel after it connects with WiFi.channel(), and set the sender's channel to match.
  • Or keep ESP-NOW boards off the router entirely and let one gateway board bridge between the two worlds.

Either way, the symptom of a channel mismatch is always the same: the sender's callback reports failures while the receiver shows nothing.

Securing the Link

By default, ESP-NOW frames are unencrypted and unauthenticated. Anyone within radio range with the right tools can read them or send forged packets that look genuine, and an attacker can also replay a captured packet. For a hobby sensor that is often acceptable. For anything that guards something, add encryption.

ESP-NOW supports CCMP encryption per peer, using a 16-byte local master key (LMK). Both boards must register each other with the same key, so the receiver also needs the sender's MAC address. A primary master key (PMK) protects the LMKs and can be set with esp_now_set_pmk(); if you do not set one, a default is used.

C++

Be realistic about what this buys you. Encryption stops casual sniffing and forged packets from boards that do not have the key. It does not protect against someone who can read the keys out of your firmware (they sit in flash unless you enable flash encryption), and broadcast frames cannot be encrypted at all. Combine it with the sequence number and reject packets whose counter does not move forward if replays matter to you.

Common ESP-NOW Mistakes

MistakeWhat you seeFix
Different channels on the two boardsSender callback always fails, receiver silentPin both with esp_wifi_set_channel()
Struct differs between sketchesReceiver shows nonsense valuesShare one definition, use fixed-width packed types
Slow work inside onReceiveDropped packets, watchdog resetsCopy and set a flag, work in loop()
Treating ESP_OK from esp_now_send as deliveryPackets "sent" but never arriveCheck the send callback status
Sending before esp_now_add_peerSend errorsRegister the peer first
Not checking esp_now_init()Cascade of unrelated errorsCheck the return value and stop
Wrong MAC addressSender reports failureRe-read the receiver's MAC, check each byte
Callback prototype from a different coreCompile errorUse the version checks shown above
Sending faster than the radio can handleSend callback out of order or failingSpace packets out; a few milliseconds minimum

Where to Take This Next

The pattern you built scales naturally:

  • Broadcast. Use the address FF:FF:FF:FF:FF:FF to send to every ESP-NOW device in range. Broadcast frames are not acknowledged, so a "success" does not mean anyone received it, and they cannot be encrypted.
  • Many to one. Several sensor nodes can send to one receiver. Identify each by the sender address supplied to the callback, or add a node ID field to the struct.
  • Two-way traffic. Both boards register each other and implement both callbacks. Use it for acknowledgements, commands or configuration.
  • Battery nodes. A sender can wake from deep sleep, transmit one packet and sleep again, which is the main reason ESP-NOW suits battery sensors. Our ESP32 Deep Sleep guide covers the wake, work, sleep pattern.
  • Bigger payloads. On ESP-IDF 5.4+ builds, ESP-NOW v2.0 allows packets far larger than 250 bytes between devices that both support it. Check what your core version exposes before relying on it.

Libraries Required

LibrarySource
Adafruit SSD1306Arduino Library Manager
Adafruit GFX LibraryArduino Library Manager
WiFi, esp_now, esp_wifiBuilt into the ESP32 Arduino core

Frequently Asked Questions

Do ESP-NOW boards need to be on the same Wi-Fi network? No. They never join one. They only need to be on the same radio channel.

How far can ESP-NOW reach? Open-air figures of a hundred metres or more are often reported with a good antenna and stable power, but walls, metal and interference reduce that sharply. Measure in your own space using the lost-packet counter.

Can I use ESP-NOW and Wi-Fi at the same time? Yes, with care. The single radio is shared, so ESP-NOW frames use the channel the Wi-Fi connection is on. Plan the channel as described above.

Can ESP32 talk to ESP8266 with ESP-NOW? Yes, they interoperate, but the ESP8266 uses different function names and roles. Check its API before copying code across.

Why does my receiver miss packets when the siren plays? In the version in this article it should not, because the callback only copies the packet and the siren is non-blocking. If you add slow work such as delay() or long display updates to loop(), the link still receives packets, but your reaction to them is delayed.

Conclusion

ESP-NOW is one of the most useful and most overlooked features of the ESP32. It delivers direct, fast, router-free communication, and the whole pattern fits on one page: share a packet definition, pick a channel, register a peer, send on one side, and copy-and-flag on the other. What turns a demo into something dependable is the engineering around that core: a sequence number to expose loss, a timeout to expose silence, short callbacks, and optional encryption.

Once this two-board link is solid, you have the foundation for multi-node sensor networks, wireless remotes and battery-powered edge devices that need no infrastructure at all.


Built with ESP32, ESP-NOW and C++. Project by Manish Pal, B.Tech Electronics & Communication Engineering.

Topicsesp32esp-nowp2pmq135ir-sensoroledwirelessarduino-ide

Questions & Answers