In a smart home, the sensors in every room have to talk to something. Connecting each one to your Wi-Fi works, but it is slow to start, uses a lot of power and fills your router with gadgets. ESP-NOW is a much simpler way, made by Espressif for its own chips: an ESP32 sends a short message straight to another ESP32's address, without a router, without logging in, and it arrives in a few milliseconds. In this capstone the ESP32 from your kit becomes a sensor node with a DHT11 and a PIR sensor. An ESP32-S3 is the hub: it receives the messages from up to 8 nodes and, because it is also on your home Wi-Fi, shows them on a live dashboard on your phone, with the signal strength of each node and how many messages got lost. Build one node, then add more boards in other rooms.
What you'll learn
- How ESP-NOW sends messages from one ESP32 to another by MAC address, without a router
- How a struct packs several readings into one message, and why both sketches must use the same one
- How acknowledgements tell the sender whether a message arrived, and how the node finds the hub's Wi-Fi channel
- How to pass messages safely from the Wi-Fi driver to loop() with a FreeRTOS queue
- How to measure a wireless link: signal strength (RSSI) and lost messages
What you need
- An ESP32-S3 board: the Espressif ESP32-S3-DevKitC-1 or a copy of it (for example N8R8 or N16R8), with a USB-C cable. It is not in the kit.
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit): the sensor node
- DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
- HC-SR501 PIR motion sensor (ESP32 IoT Starter Kit)
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
- A 2.4 GHz Wi-Fi network for the hub, and a USB phone charger or power bank to put the node in another room
About the ESP32-S3-DevKitC-1: it is longer than the ESP32 board from the kit and has two USB-C ports. Use the one labelled UART (or COM) for uploading and the Serial Monitor. The small LED near the ports is an RGB LED that the sketch can set to any colour. The GPIO numbers are printed next to the pins. The ESP32-S3 works at 3.3 V like the classic ESP32, so the same sensors work with it.
Set up the ESP32-S3 in the Arduino IDE
- In the Arduino IDE, open Boards Manager and install or update esp32 by Espressif Systems to version 3 or newer. The ESP32-S3 support, Wi-Fi, Bluetooth, LittleFS and ESP-NOW all come with it.
- Choose Tools → Board → esp32 → ESP32S3 Dev Module.
- Plug the board into the UART port and choose its port under Tools → Port.
- If an upload ever fails with "Failed to connect", hold the BOOT button, press and let go of RST, let go of BOOT, and upload again. Press RST once more afterwards to start the sketch.
The node is the ESP32 from your kit. For its sketch, choose Tools → Board → esp32 → ESP32 Dev Module as usual. If you have both boards plugged in, check under Tools → Port that you upload to the right one.
Wiring
The hub needs no wiring at all: only its USB cable. The sensors go on the node (the kit's ESP32):

Build it step by step
Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.



| From | To |
|---|---|
| DHT11 module S (signal) | ESP32 GPIO 27 |
| DHT11 module + and − | 3V3 and GND |
| PIR sensor OUT | ESP32 GPIO 26 |
| PIR sensor VCC | 5V |
| PIR sensor GND | GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
The sketches
In the Arduino IDE, open Library Manager and install the DHT sensor library by Adafruit (for the node).
There are two sketches. Upload the hub first: put your Wi-Fi name and password into it.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
// ESP-NOW HUB (ESP32-S3): receives readings from the sensor nodes and shows them on a web page
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <esp_now.h>
// ---------- settings ----------
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* HUB_NAME = "sensorhub"; // the page is at http://sensorhub.local
const int RGB_PIN = RGB_BUILTIN; // DevKitC-1 v1.1: use 38 instead
const int MAX_NODES = 8;
const unsigned long OFFLINE_MS = 30000; // a node that is quiet for 30 s is shown as offline
// One message from a node. It must be exactly the same in the node sketch.
struct Reading {
uint8_t version; // 1
char name[16]; // "Living room"
float temp, hum; // °C and %
uint8_t motion; // 1 while the PIR sees movement
uint16_t motionCount; // movements since the node started
uint32_t sent; // how many messages the node has sent
} __attribute__((packed));
struct Packet { uint8_t mac[6]; int rssi; Reading r; };
struct Node {
bool used;
uint8_t mac[6];
Reading r;
int rssi;
unsigned long seen, lastMotion;
uint32_t received;
};
Node nodes[MAX_NODES];
QueueHandle_t inbox; // messages wait here until loop() takes them
WebServer server(80);
unsigned long ledOffAt = 0;
const char PAGE[] = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Sensor network</title>
<style>
body{margin:0;font-family:Arial,sans-serif;background:#F6F8FC;color:#1B3A7B}
header{background:#1B3A7B;color:#fff;padding:12px 16px;display:flex;justify-content:space-between;align-items:center}
header b{font-size:20px} #info{font-size:14px;opacity:.85}
#nodes{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin:12px}
.node{background:#fff;border-radius:16px;padding:14px 16px;box-shadow:0 2px 8px rgba(27,58,123,.08)}
.node.off{opacity:.5} .top{display:flex;justify-content:space-between;align-items:center}
.top b{font-size:19px} .bars{font-size:13px;opacity:.75}
.vals{display:flex;gap:18px;margin:10px 0} .vals div{font-size:13px;opacity:.75} .vals b{display:block;font-size:26px;opacity:1}
.motion{padding:8px 10px;border-radius:10px;background:#E3E8F2;font-weight:bold}
.motion.now{background:#DE544A;color:#fff} small{display:block;margin-top:8px;opacity:.6}
#empty{margin:20px;font-size:17px}
</style></head><body>
<header><b>Sensor network</b><span id="info">-</span></header>
<div id="nodes"></div><div id="empty">Waiting for the first node...</div>
<script>
function ago(s) { return s < 60 ? s + ' s ago' : s < 3600 ? Math.floor(s / 60) + ' min ago' : Math.floor(s / 3600) + ' h ago'; }
function bars(rssi) { var n = rssi > -55 ? 4 : rssi > -67 ? 3 : rssi > -78 ? 2 : 1; return '▮'.repeat(n) + '▯'.repeat(4 - n) + ' ' + rssi + ' dBm'; }
function update() {
fetch('/nodes').then(function (r) { return r.json(); }).then(function (d) {
document.getElementById('info').textContent = d.nodes.length + ' node' + (d.nodes.length == 1 ? '' : 's') + ' · Wi-Fi channel ' + d.channel;
document.getElementById('empty').style.display = d.nodes.length ? 'none' : 'block';
document.getElementById('nodes').innerHTML = d.nodes.map(function (n) {
var lost = n.sent > 0 ? Math.max(0, Math.round(100 - 100 * n.received / n.sent)) : 0;
return '<div class="node' + (n.online ? '' : ' off') + '"><div class="top"><b>' + n.name + '</b><span class="bars">' + bars(n.rssi) + '</span></div>' +
'<div class="vals"><div>Temperature<b>' + n.temp.toFixed(1) + ' °C</b></div><div>Humidity<b>' + n.hum.toFixed(0) + ' %</b></div></div>' +
'<div class="motion' + (n.motion ? ' now' : '') + '">' + (n.motion ? 'Movement now' : n.lastMotion < 0 ? 'No movement yet' : 'Last movement ' + ago(n.lastMotion)) + ' · ' + n.count + ' in total</div>' +
'<small>' + (n.online ? 'Online' : 'OFFLINE') + ' · last message ' + ago(n.seen) + ' · ' + n.mac + ' · ' + lost + ' % of messages lost</small></div>';
}).join('');
}).catch(function () { document.getElementById('info').textContent = 'No connection'; });
}
update(); setInterval(update, 2000);
</script></body></html>)rawliteral";
// Runs inside the Wi-Fi driver whenever an ESP-NOW message arrives: copy it and get out quickly.
void onReceive(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
if (len != sizeof(Reading)) return; // not one of our messages
Packet p;
memcpy(p.mac, info->src_addr, 6);
p.rssi = info->rx_ctrl->rssi;
memcpy(&p.r, data, len);
xQueueSend(inbox, &p, 0);
}
int findNode(const uint8_t* mac) { // the node's place in the table, or a free place
for (int i = 0; i < MAX_NODES; i++) if (nodes[i].used && memcmp(nodes[i].mac, mac, 6) == 0) return i;
for (int i = 0; i < MAX_NODES; i++) if (!nodes[i].used) return i;
return -1;
}
void handlePacket(const Packet& p) {
if (p.r.version != 1) return;
int i = findNode(p.mac);
if (i < 0) return; // the table is full
Node& n = nodes[i];
if (!n.used) {
n.used = true;
memcpy(n.mac, p.mac, 6);
n.lastMotion = 0;
n.received = 0;
Serial.printf("New node: %s\n", p.r.name);
}
if (p.r.motion && (!n.r.motion || n.lastMotion == 0)) n.lastMotion = millis();
n.r = p.r;
n.r.name[15] = 0; // make sure the name ends
n.rssi = p.rssi;
n.seen = millis();
n.received++;
rgbLedWrite(RGB_PIN, 0, 0, 30); // short blue flash for every message
ledOffAt = millis() + 80;
}
String jsonText(const char* s) { // a node name, safe to put into JSON and HTML
String out;
for (; *s; s++) {
if (*s == '"' || *s == '\\' || *s == '<' || *s == '>' || *s < 32) continue;
out += *s;
}
return out;
}
void sendNodes() {
unsigned long now = millis();
String json = "{\"channel\":" + String(WiFi.channel()) + ",\"nodes\":[";
bool first = true;
for (int i = 0; i < MAX_NODES; i++) {
Node& n = nodes[i];
if (!n.used) continue;
char mac[18];
snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", n.mac[0], n.mac[1], n.mac[2], n.mac[3], n.mac[4], n.mac[5]);
if (!first) json += ",";
first = false;
json += "{\"name\":\"" + jsonText(n.r.name) + "\",\"mac\":\"" + mac + "\",\"temp\":" + String(n.r.temp, 1) +
",\"hum\":" + String(n.r.hum, 0) + ",\"motion\":" + n.r.motion + ",\"count\":" + n.r.motionCount +
",\"lastMotion\":" + (n.lastMotion ? String((now - n.lastMotion) / 1000) : String(-1)) +
",\"seen\":" + String((now - n.seen) / 1000) + ",\"online\":" + (now - n.seen < OFFLINE_MS ? "true" : "false") +
",\"rssi\":" + n.rssi + ",\"sent\":" + n.r.sent + ",\"received\":" + n.received + "}";
}
server.send(200, "application/json", json + "]}");
}
void setup() {
Serial.begin(115200);
inbox = xQueueCreate(16, sizeof(Packet));
rgbLedWrite(RGB_PIN, 15, 15, 15);
WiFi.mode(WIFI_STA); // ESP-NOW works next to a normal Wi-Fi connection
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println();
WiFi.setSleep(false); // ESP-NOW needs the radio awake all the time
if (esp_now_init() != ESP_OK) Serial.println("ESP-NOW failed to start");
esp_now_register_recv_cb(onReceive);
MDNS.begin(HUB_NAME);
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/nodes", sendNodes);
server.begin();
Serial.println("Copy this line into the node sketch:");
uint8_t mac[6];
WiFi.macAddress(mac);
Serial.printf("uint8_t HUB_MAC[6] = {0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X};\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
Serial.printf("Wi-Fi channel: %d\n", WiFi.channel());
Serial.printf("Dashboard: http://%s.local or http://", HUB_NAME);
Serial.println(WiFi.localIP());
rgbLedWrite(RGB_PIN, 0, 0, 0);
}
void loop() {
server.handleClient();
Packet p;
while (xQueueReceive(inbox, &p, 0) == pdTRUE) handlePacket(p);
if (ledOffAt && millis() > ledOffAt) {
rgbLedWrite(RGB_PIN, 0, 0, 0);
ledOffAt = 0;
}
}Open the Serial Monitor at 115200 baud and press RST on the ESP32-S3. The hub prints a line like uint8_t HUB_MAC[6] = {0x34, 0x85, 0x18, 0x9A, 0x2C, 0x10};. That is the hub's MAC address, its unique number in the world. Copy the whole line into the node sketch, in place of the line with all the zeros, and give the node a name. The node doesn't need your Wi-Fi password.
// ESP-NOW SENSOR NODE (the ESP32 from the kit): sends temperature, humidity and movement to the hub
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <DHT.h>
// ---------- settings ----------
const char* NODE_NAME = "Living room"; // up to 15 characters, shown on the dashboard
uint8_t HUB_MAC[6] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; // paste the line from the hub's Serial Monitor
const int DHT_PIN = 27;
const int PIR_PIN = 26;
const unsigned long EVERY_MS = 10000; // send a reading every 10 s (and at once when movement starts)
struct Reading { // exactly the same as in the hub sketch
uint8_t version;
char name[16];
float temp, hum;
uint8_t motion;
uint16_t motionCount;
uint32_t sent;
} __attribute__((packed));
DHT dht(DHT_PIN, DHT11);
Reading r;
int channel = 0; // the hub's Wi-Fi channel, found by trying
int failures = 0;
bool lastPir = false;
unsigned long lastSend = 0;
volatile bool sendDone = false, sendOk = false;
// The hub answers every message with a short "got it" (an acknowledgement). This tells us if it arrived.
#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
sendOk = (status == ESP_NOW_SEND_SUCCESS);
sendDone = true;
}
void useChannel(int ch) {
esp_wifi_set_promiscuous(true);
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
esp_wifi_set_promiscuous(false);
}
bool sendReading() { // returns true if the hub got it
r.sent++;
sendDone = false;
if (esp_now_send(HUB_MAC, (uint8_t*)&r, sizeof(r)) != ESP_OK) return false;
unsigned long start = millis();
while (!sendDone && millis() - start < 100) delay(1);
return sendDone && sendOk;
}
// The hub uses the channel of your home Wi-Fi. Try channels 1 to 13 until the hub answers.
void findHub() {
for (int ch = 1; ch <= 13; ch++) {
useChannel(ch);
if (sendReading()) {
channel = ch;
Serial.printf("Found the hub on channel %d\n", ch);
return;
}
r.sent--; // wrong channel: not a real lost message
}
channel = 0;
Serial.println("Hub not found: is it switched on, and is HUB_MAC right?");
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
dht.begin();
memset(&r, 0, sizeof(r));
r.version = 1;
strncpy(r.name, NODE_NAME, sizeof(r.name) - 1);
WiFi.mode(WIFI_STA); // the radio on, but not connected to any network
if (esp_now_init() != ESP_OK) Serial.println("ESP-NOW failed to start");
esp_now_register_send_cb(onSent);
esp_now_peer_info_t hub = {};
memcpy(hub.peer_addr, HUB_MAC, 6);
hub.channel = 0; // 0 = whatever channel the radio is on
hub.encrypt = false;
esp_now_add_peer(&hub);
delay(2000); // let the DHT11 and the PIR wake up
lastSend = millis() - EVERY_MS; // send the first reading at once
}
void loop() {
bool pir = digitalRead(PIR_PIN);
bool startMoving = pir && !lastPir;
lastPir = pir;
if (startMoving) r.motionCount++;
if (startMoving || millis() - lastSend >= EVERY_MS) {
lastSend = millis();
float t = dht.readTemperature(), h = dht.readHumidity();
if (!isnan(t) && !isnan(h)) { r.temp = t; r.hum = h; }
r.motion = pir;
if (channel == 0) findHub();
else if (sendReading()) {
failures = 0;
Serial.printf("Sent: %.1f °C, %.0f %%, movement %d\n", r.temp, r.hum, r.motion);
} else if (++failures >= 3) {
Serial.println("The hub doesn't answer: searching again");
findHub();
failures = 0;
}
}
delay(20);
}
Try it
- With the hub running, upload the node sketch to the kit's ESP32 and open its Serial Monitor. After about 2 seconds it says "Found the hub on channel 6" (or another number), then "Sent: 22.4 °C, 45 %, movement 0" every 10 seconds.
- On your phone, open
http://sensorhub.local(or the number address from the hub's Serial Monitor). A card for "Living room" appears with the temperature and humidity. The S3's LED flashes blue for every message it receives. - Wave in front of the PIR: within a fraction of a second the card turns red and says "Movement now", because the node sends at once instead of waiting for the next 10 seconds.
- Unplug the node from the computer and power it from a phone charger or power bank in another room. Watch the signal bars and the "lost" number: walls, floors and people all weaken the signal.
- Switch the node off. After 30 seconds its card fades and says OFFLINE. Switch it on again: it finds the hub by itself.
- Have another ESP32? Upload the node sketch with a different
NODE_NAMEand see a second card appear. The hub keeps up to 8 nodes.
What you should see

How it works

Every Wi-Fi chip has a MAC address, six bytes that no other chip in the world has. ESP-NOW uses it like a phone number: esp_now_send(HUB_MAC, ...) sends up to 250 bytes straight to that chip. There is no router, no password and no connection to set up, so a message arrives in a few milliseconds and the node can send right after it switches on. This is why ESP-NOW is popular for battery-powered sensors, remote controls and drones.
Both sketches describe the message with the same struct: a version number, the node's name, temperature, humidity, the movement state and two counters. The node sends the struct's bytes exactly as they lie in memory, and the hub lays them over its own copy of the struct. That only works if the structs match byte for byte. __attribute__((packed)) stops the compiler from leaving gaps between the fields. The version field lets the hub ignore messages in an old format if you ever change the struct.
Every message to a MAC address is answered by the receiving chip with a tiny acknowledgement, and the sender's onSent() callback learns whether it arrived. The node uses this in a clever way. ESP-NOW only works when both boards listen on the same Wi-Fi channel, and the hub must stay on the channel of your router, which can be any number from 1 to 13. So the node simply tries: it sends on channel 1, waits 100 ms for an acknowledgement, then tries channel 2, and so on. If later three messages in a row get no answer (for example because the router changed channel), it searches again.
On the hub, onReceive() is called by the Wi-Fi driver itself, in the middle of its own work, so it must be quick. It only copies the message and its RSSI (received signal strength, in dBm: −40 is very strong, −90 is the edge of range) into a queue, a FreeRTOS mailbox that is safe to use between tasks. loop() takes messages out of the queue and updates the table of nodes. The node counts every message it sends in sent, and the hub counts what it receives, so the dashboard can show what share got lost on the way. ESP-NOW tries each message a few times before it gives up, so a few percent lost means the link is at its limit.
Try this next
- Make a battery node: let it send, then go into deep sleep for 5 minutes with
esp_deep_sleep(300e6). Because ESP-NOW needs no connection, each wake-up can be very short. Keep the channel withRTC_DATA_ATTR int channel = 0;so it survives deep sleep and the node doesn't have to search every time, and shorten the 2-second wait insetup(). - Add a door sensor to a node, using the reed switch from the door counter, and add a "door open" field to the struct (in both sketches, with
version = 2). - Send commands the other way: let the hub send a message to a node to switch a relay, so the dashboard gets buttons.
- Log the readings of every node on the hub, as in the ESP32-S3 room monitor, and draw a chart for each room.
- Walk around the house with the node on a power bank and draw a map of the signal strength in each room.
Troubleshooting
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
- "Hub not found" on the node: check that the hub is running and connected to Wi-Fi, and that you copied the HUB_MAC line from the hub's Serial Monitor exactly. The node needs the hub's address, not its own.
-
The node finds the hub, but no card appears: both sketches must have exactly the same
Readingstruct. If you changed one, change the other too. - Nothing appears in the hub's Serial Monitor: plug the cable into the port labelled UART (or COM), or set Tools → USB CDC On Boot to Enabled when you use the port labelled USB.
- Many messages are lost: move the node closer, or lift it up. Metal, water (a fish tank, a fridge) and thick walls block 2.4 GHz radio. The ESP32-S3-DevKitC-1 has its antenna at the end of the board: don't cover it.
- The temperature shows 0.0 °C: the DHT11 is not answering. Check the S wire on GPIO 27 and its + and − pins.
- The movement card stays red: turn the PIR's delay (Tx) knob fully anticlockwise, so it releases after a few seconds.
-
The hub's LED stays off: on the ESP32-S3-DevKitC-1 v1.1 the RGB LED is on GPIO 38. Set
RGB_PIN = 38.