Blog

ESP-NOW: two ESP32 boards that find each other with no router and no pairing

tutorial


Two microcontrollers on a desk, and you want them to talk. The usual answer involves an access point, credentials compiled into both images, a DHCP lease, and a discovery protocol on top so they can find each other's addresses. That is a lot of moving parts for two boards a metre apart.

ESP-NOW skips all of it. It is Espressif's connectionless protocol: raw 802.11 action frames, sent directly from one radio to another. No association, no IP stack, no router. A board can send a frame roughly as soon as the radio is initialised.

This tutorial flashes the same firmware to two boards and has each one list the other, with the signal strength of both directions of the link. Nothing is configured — not an SSID, not a MAC address, not a role.

What you need#

Two boards. One board flashed with this will sit there reporting an empty roster, which is correct and not interesting. They do not have to be the same model.

Not the ESP32-H2. It has 802.15.4 and Bluetooth LE, but no WiFi radio, and ESP-NOW rides on WiFi. The other five supported chips all work: ESP32, C3, C6, S2 and S3.

Two USB cables, and Chrome or Edge. If you have not flashed a board from the browser before, the blinky tutorial covers connecting and flashing in more detail than this one will.

Open the sketch#

BoardPlayground
ESP32Open →
ESP32-C3Open →
ESP32-C6Open →
ESP32-S2Open →
ESP32-S3Open →

One binary, no roles#

There is no sender image and no receiver image. Every board broadcasts a beacon twice a second and listens the rest of the time. That symmetry is a deliberate constraint: a demo you can hand to someone else has to be the same bytes on both ends, or the first question is always "which one do I flash where".

Each beacon carries the sender's own MAC and, alongside it, how well the sender is currently hearing the peers it already knows about. That second part is what makes the display worth looking at. Radio links are not symmetric — antenna orientation, ground planes and nearby metal all affect the two directions differently — so a board reports both rssi (how well I hear you) and heard (how well you say you hear me), and they are frequently several dB apart.

Use esp-radio, not esp-wifi#

If you search for ESP-NOW in Rust you will find esp-wifi, and it is a dead end. It stopped at 0.15.1 in October 2025, compiles only against esp-hal 1.0.0-rc.0, and separately wants a builtin-scheduler feature that exists only in esp-hal 1.1.x. Those two requirements cannot both be satisfied.

esp-radio is the same driver, renamed and still maintained, and it tracks current esp-hal. Here is what this sketch adds on top of what blinky already needed (esp-backtrace, esp-println and esp-bootloader-esp-idf are still there, unchanged):

[dependencies]
esp-hal   = { version = "1.1.1",  features = ["esp32c3", "unstable"] }
esp-radio = { version = "0.18.0", features = ["esp32c3", "esp-now", "wifi", "unstable"] }
esp-rtos  = { version = "0.3.0",  features = ["esp32c3", "esp-radio", "embassy"] }
esp-alloc = "0.10.0"
embassy-executor = "0.10.0"
embassy-futures  = "0.1"
embassy-time     = "0.5"

esp-rtos is not optional. The WiFi driver is a large blob of Espressif C underneath, and it expects a scheduler with real tasks it can block on. Bringing in esp-rtos also gets you the embassy executor, which is why this sketch is async where blinky was a blocking loop.

esp-alloc is there because received frames arrive as Box<[u8]>. A 72 KB heap is plenty:

esp_alloc::heap_allocator!(size: 72 * 1024);

Bringing the radio up#

let mut esp_now = interfaces.esp_now;
esp_now.set_channel(CHANNEL).expect("could not set channel");

Both boards must be on the same channel or they will never hear each other, and nothing here joins a network to inherit one, so the sketch fixes it:

const CHANNEL: u8 = 11;

Eleven rather than one. ESP-NOW frames get no acknowledgement and no retry, so a beacon that collides with nearby WiFi traffic is simply gone. Measured across a desk, channel 1 lost around 3% of beacons at -49 dBm while channel 11 lost essentially none — 1, 6 and 11 are the non-overlapping channels, and consumer routers pick 1 or 6 far more often than 11. If your own network happens to sit on 11, move the sketch to 8.

Transmit power is set to 11 dBm, well below the 20 dBm maximum:

const TX_POWER_QDBM: i8 = 44;  // quarter-dBm units

That is not a typo or a conservatism reflex. On many C3 SuperMini boards the crystal sits close enough to the antenna that running the radio flat out makes the signal worse. Turning it down measurably improves range on those layouts. Raise it if you know your board is laid out properly.

The main loop#

loop {
    match select(Timer::at(next_beacon), esp_now.receive_async()).await {
        Either::First(_) => {
            // beacon due: build the frame and broadcast it
            let _ = esp_now.send_async(&BROADCAST_ADDRESS, &buf[..len]).await;
        }
        Either::Second(rx) => {
            // a frame arrived: update the roster
        }
    }
}

select from embassy-futures races two futures and resumes on whichever finishes first. There is no polling and no fixed tick: the task sleeps until either the next beacon is due or a frame arrives. On a blocking Delay the board would be deaf for the whole 500 ms between beacons.

Frames go to BROADCAST_ADDRESS rather than to a peer, which is what removes pairing from the picture. Every payload starts with an eight-byte magic prefix ending in a version byte, so the roster fills only with boards running this sketch and not with whatever else is broadcasting nearby.

The roster is a fixed [Option<Peer>; 8] — no allocation on the receive path, and a list longer than eight is not readable on a screen anyway. Each beacon carries a sequence number, and the gaps in that sequence are where the loss count comes from.

The RSSI sign bug#

One detail worth stealing, because it will bite you the first time you read signal strength from this API:

fn to_dbm(raw: i32) -> i8 {
    raw as u8 as i8
}

RSSI arrives as an i32 that has already been widened from a byte without regard for sign, so -41 dBm shows up as 215. Casting straight to i8 would be wrong for a different reason — the value has to go back through u8 first to drop the garbage in the high bytes, and only then be reinterpreted as signed. Skip that and you get plausible-looking positive signal strengths, which is exactly the kind of bug that survives review because the number is not obviously absurd.

Flash both boards#

Flash the first board the usual way: Build, Connect Device, Flash. When the monitor comes up you will see it announce itself and then report an empty roster, which is what one board alone should do:

esp-now presence — this board is 8c:bf:ea:cb:11:04
flash the same firmware on another board and they will find each other
channel 11, beacon every 500 ms

Now the second board. You can reuse the same tab: unplug the first board, press Connect Device again, pick the second one, and press Flash. The image is already built, so this takes seconds. If your second board is a different model, switch the target board first and rebuild — the flasher will refuse a mismatched image before writing anything, so a mistake here costs a message, not a recovery.

Plug the first board back into any USB port. It only needs power; it does not need to be the port the browser is watching.

Within a second or so, the connected board notices the other one:

peer joined: b8:1f:3f:fd:b4:f4
@peer mac=b81f3ffdb4f4 rssi=-41 heard=-45 loss=0 age=118 n=1

Open the Peers tab in the drawer and that line becomes a live roster with signal bars. rssi is how well this board hears the peer; heard is what the peer says about hearing this board; loss counts beacons missing from the sequence; age is milliseconds since the last one arrived.

Things worth trying#

Walk one board away from the other and watch both numbers fall — and watch them fall at different rates, which is the asymmetry made visible. Put a hand or a laptop between them and the drop is immediate and large; 2.4 GHz is absorbed well by anything mostly water.

Power one board from a battery or a phone charger and carry it around the flat. The roster entry disappears when nothing has been heard for a couple of seconds and comes back on its own when you return, with the loss count showing what was missed in between.

Flash a third board, if you have one. The protocol has room for eight peers and reports four per beacon. Two boards is verified on real hardware; more than two is not, so if you find something odd there, you found it first.

When it doesn't work#

Both boards flash fine but neither sees the other. Check that both are actually running this sketch and on the same channel — if you edited CHANNEL on one, edit it on both. Both must also be genuinely powered; a board that only gets power when the browser is connected to it will drop off when you unplug it.

The roster is empty on one side but populated on the other. That is a real one-way link, not a bug. Move them closer and see if it becomes symmetric.

Positive RSSI values. You changed to_dbm, or removed it. See above.

chip mismatch: connected ESP32-C3, built for esp32-c6. You are flashing the second board without switching the target. Change the board, rebuild, flash.

The build fails on esp-radio. Check that the chip feature matches on every crate in Cargo.toml. esp-hal, esp-radio, esp-rtos, esp-backtrace, esp-println and esp-bootloader-esp-idf each take their own esp32c3-style feature, and one left behind after a board switch produces a long error that names symbols rather than the actual problem.

For anything about cables, ports or the flashing itself, the blinky tutorial has the longer list.


Ready to try it? Open the playground — it compiles on the build farm and flashes over Web Serial, with nothing to install.