Two ESP32 boards can exchange a small sensor reading without joining your home Wi-Fi. In this build one board reads a DHT22; the other prints the received temperature, humidity and packet sequence to Serial Monitor.
Allow 60–90 minutes and keep both boards on the same bench to begin. You will learn more by deliberately breaking the channel setting once than by copying a wireless example and hoping it works.
Try the walkie-talkie idea
Try it yourself
Think of two walkie-talkies. They speak directly, but both must use the same channel. A destination MAC address is the label on the message: it identifies which board should receive it.
Matching channels are necessary, not a delivery guarantee. Real links also depend on power, range and interference. A successful send call only queues a packet; the receiver log confirms application reception in this tutorial.
Send on matching channels, change only the receiver channel, then try a blocked link. The channel mismatch is intentional; actual radio delivery has more variables.
A Wi-Fi router normally coordinates a network. ESP-NOW instead exchanges small messages directly using the Wi-Fi radio. The walkie-talkie analogy explains why both boards must listen on the same channel. A MAC address adds something the analogy lacks: a specific destination for a unicast packet.
This tutorial uses a simple unencrypted learning link and a fixed 16-byte packet. It does not implement authentication or application acknowledgements, so do not use it for secrets, access control or critical actuation.
Parts and software
🧰 Build kit
- 2×ESP32 development boardUse two classic ESP32 DevKit boards for this exact exampleGet it →
- 1×DHT22 sensorBare four-pin device or labelled breakoutGet it →
- 1×10k resistorBare-sensor DATA pull-up to 3V3Get it →
- 2×USB cableOne for each board; use data-capable cablesGet it →
- 1 set×Breadboard and jumper wiresNo wire connects the two ESP32 boardsGet it →
In Arduino IDE install the Espressif ESP32 board package, Adafruit DHT sensor library and its Adafruit Unified Sensor dependency. These sketches were compile-checked for ESP32 Dev Module with Arduino-ESP32 3.3.8, DHT sensor library 1.4.7 and Unified Sensor 1.1.15. The receive callback uses the Arduino-ESP32 3.x form; old 2.x examples use a different signature.
Wire only the sensor board
Follow a reading from the sensor to GPIO4
Power: For this 3.3 V setup, connect VCC and GND to the sensor board. Bare DHT22 pins, front facing you: 1 VCC, 2 DATA, 3 unused, 4 GND.
Check the bare-sensor pin order and pull-up. On a breakout, follow its actual pin labels instead.
For a bare DHT22, hold the front grille toward you with the legs pointing down: VCC, DATA, unused and GND from left to right. Power VCC from 3V3, join GND and connect DATA to GPIO4. Add 10 kΩ between DATA and 3V3. On a module, follow its printed pin names; the order can differ and the pull-up may already be fitted.
Keep the DHT data pull-up at 3.3 V when it feeds an ESP32 input. Leave the two boards electrically separate apart from their own USB supplies. ESP-NOW itself is the link between them.
Start with the receiver
Upload the receiver sketch first. Open Serial Monitor at 115200 and copy the six-byte address printed after Receiver STA MAC:. Keep this board running. Its channel is explicitly set to 1.
The callback runs in the Wi-Fi task. Instead of printing or waiting inside it, the sketch checks the packet shape and places a copy in a small queue. The normal loop reads the queue and prints the data. That keeps the radio callback short.
/* Kadosh Build: receiver. Classic ESP32 DevKit; Arduino-ESP32 3.x.
This learning link is unencrypted. Do not transmit secrets.
Original example code, MIT license. */
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <math.h>
constexpr uint8_t CHANNEL = 1;
constexpr uint32_t MAGIC = 0x4B424C31;
struct Packet { uint32_t magic; uint32_t sequence; float temperatureC; float humidityPct; };
static_assert(sizeof(Packet) == 16, "Packet layout differs");
QueueHandle_t packets;
uint32_t lastPacketAt = 0, lastSequence = 0;
bool received = false, warned = false;
void onReceive(const esp_now_recv_info_t* info, const uint8_t* bytes, int len) {
if (!info || len != sizeof(Packet)) return;
Packet packet;
memcpy(&packet, bytes, sizeof(packet));
if (packet.magic != MAGIC || !isfinite(packet.temperatureC) || !isfinite(packet.humidityPct)) return;
// Wi-Fi task callback: queue the reading and return promptly.
xQueueSend(packets, &packet, 0);
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA); WiFi.setSleep(false);
if (esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK) {
Serial.println("Channel setup failed"); while (true) delay(1000);
}
Serial.print("Receiver STA MAC: "); Serial.println(WiFi.macAddress());
packets = xQueueCreate(4, sizeof(Packet));
if (!packets || esp_now_init() != ESP_OK || esp_now_register_recv_cb(onReceive) != ESP_OK) {
Serial.println("ESP-NOW setup failed"); while (true) delay(1000);
}
lastPacketAt = millis();
Serial.println("Ready on channel 1. Copy the MAC into the sender sketch.");
}
void loop() {
Packet packet;
if (xQueueReceive(packets, &packet, 0) == pdTRUE) {
const uint32_t gap = received && packet.sequence > lastSequence ? packet.sequence - lastSequence - 1 : 0;
// A lower sequence can be a sender reboot: establish a fresh baseline.
lastSequence = packet.sequence; received = true; warned = false; lastPacketAt = millis();
Serial.printf("seq=%lu temp=%.1fC humidity=%.1f%% gap=%lu\n", (unsigned long)packet.sequence, packet.temperatureC, packet.humidityPct, (unsigned long)gap);
}
if (!warned && millis() - lastPacketAt > 10000UL) {
Serial.println(received ? "STALE: no new packet for 10 seconds" : "WAITING: no packet received yet");
warned = true;
}
delay(5);
}Configure and upload the sender
Replace the six bytes in receiverMac with the address you just copied. For example, an address written as AA:BB:CC:DD:EE:FF becomes {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}. The example address in the sketch is a placeholder; it is not your board.
Leave CHANNEL at 1 on both boards. The sender waits 2.5 seconds between reads and skips a packet if the DHT returns an invalid number. Both programs use the same Packet field order, field types and magic value.
/* Kadosh Build: DHT22 sender. Classic ESP32 DevKit; Arduino-ESP32 3.x.
Install Adafruit DHT sensor library and Adafruit Unified Sensor.
DHT22 DATA -> GPIO4; 10k pull-up from DATA to 3V3 for a bare sensor.
Original example code, MIT license. */
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <DHT.h>
#include <math.h>
constexpr uint8_t CHANNEL = 1, DHT_PIN = 4;
constexpr uint32_t MAGIC = 0x4B424C31;
// Replace all six bytes with the receiver's printed STA MAC. This is a placeholder.
uint8_t receiverMac[] = {0x24, 0x6F, 0x28, 0x00, 0x00, 0x01};
struct Packet { uint32_t magic; uint32_t sequence; float temperatureC; float humidityPct; };
static_assert(sizeof(Packet) == 16, "Packet layout differs");
DHT sensor(DHT_PIN, DHT22);
uint32_t sequence = 0, previous = 0;
void setup() {
Serial.begin(115200); sensor.begin();
WiFi.mode(WIFI_STA); WiFi.setSleep(false);
if (esp_wifi_set_channel(CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK || esp_now_init() != ESP_OK) {
Serial.println("ESP-NOW setup failed"); while (true) delay(1000);
}
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, receiverMac, 6);
peer.channel = CHANNEL; peer.ifidx = WIFI_IF_STA; peer.encrypt = false;
if (esp_now_add_peer(&peer) != ESP_OK) {
Serial.println("Peer setup failed"); while (true) delay(1000);
}
Serial.println("Sender ready. Receiver must use the same channel.");
}
void loop() {
if (millis() - previous < 2500UL) return;
previous = millis();
const float t = sensor.readTemperature(), h = sensor.readHumidity();
if (isnan(t) || isnan(h)) { Serial.println("DHT read failed: check wiring and pull-up"); return; }
Packet packet = {MAGIC, ++sequence, t, h};
const esp_err_t result = esp_now_send(receiverMac, reinterpret_cast<const uint8_t*>(&packet), sizeof(packet));
Serial.printf("seq=%lu temp=%.1fC humidity=%.1f%% %s\n", (unsigned long)sequence, t, h, result == ESP_OK ? "queued (check receiver log)" : "queue failed");
}Read the logs like evidence
Queued is different from received
Sample: The sensor board takes a DHT22 reading and increments its packet sequence.
Step through sampling, queuing, receiver handling and stale-data detection. Time is compressed for teaching.
| Message | What it proves | What it does not prove |
|---|---|---|
| Sender: queued | The API accepted a send request | That the receiver application received it |
| Receiver: seq=7 ... | The receiver decoded packet 7 | That every earlier packet arrived |
| Receiver: gap=2 | Two sequence numbers were skipped since the previous packet | The exact radio cause of the loss |
| Receiver: STALE | No new packet for ten seconds | Whether the cause is power, wiring or radio |
Compare sender and receiver sequence numbers while the boards are side by side. The receiver prints a gap count between increasing sequence numbers. If the sender restarts and its count goes backwards, the receiver establishes a fresh baseline rather than reporting a huge unsigned-number loss.
A four-entry queue can overflow if the application is too slow. In that situation a sequence gap can reflect application buffering, not just a radio problem. Treat the count as a diagnostic clue rather than a calibrated radio measurement.
Break it on purpose, then recover
| Experiment | Expected observation | Recovery |
|---|---|---|
| Set sender channel to 6 only | Receiver stops receiving and later prints STALE | Restore both to channel 1 |
| Unplug the sensor DATA lead | Sender prints DHT read failed | Power off, restore wiring, power on |
| Disconnect sender power | Receiver prints STALE after ten seconds | Reconnect sender power |
| Move boards farther apart | Reception may become intermittent | Record distance and obstructions; return to bench |
Use the worksheet to record packet counts and conditions for a fixed observation window, such as 60 seconds. Change one thing at a time. Do not claim a maximum range from a single successful packet; antenna orientation, obstructions and local interference matter.
Troubleshooting
| Problem | Check first |
|---|---|
| No receiver packets | Receiver STA MAC, identical channel and correct board port |
| Compile error around the callback | Use the ESP32 3.x board package for these sketches |
| DHT reads fail | DATA pin, 3V3 pull-up, sensor type DHT22 and pin order |
| Works until connecting to Wi-Fi | The Wi-Fi connection can change the radio channel; this build intentionally stays disconnected |
| Fresh data stops but old numbers remain visible | Use the STALE message; add a prominent stale-data indicator to any future display |
Next: give the data a purpose
Replace the DHT fields with a water-level reading from the tank tutorial, keeping the packet structure identical at both ends. Add application acknowledgements if you need to know that the receiver processed a particular message. Add authentication and encryption before handling sensitive data or controlling real equipment.
The browser animation is an explanation, not a measurement of RF behaviour. The sketches compile for the versions listed above; carry out the bench tests on your own boards to validate the real link.
References and further reading: Espressif ESP-NOW Arduino guide · ESP-NOW protocol and callback guidance · Adafruit DHT wiring guide
Did you build it? 🎉
Tell us if this tutorial helped and share it with your classmates.
Need parts or a hand with your project?
Get the components from our store, or talk to a Kadosh engineer about your build or research.
Questions & comments
Stuck on a step? Ask here — our engineers and other builders reply.
Sign in to ask a question or leave a comment.
Sign inCreate a free account