technique
ESP-NOW and Bluetooth LE
Talking board to board without a router using ESP-NOW, and to phones and computers with Bluetooth Low Energy.
Before this
This page assumes you are comfortable with:
- prerequisiteNetworking basicsJust enough networking for a microcontroller: WiFi joining, IP addresses, TCP and UDP, HTTP, and publish-subscribe messaging.
- techniqueChips, boards, and configsThe ESP32 family as the ESP32 Inspector catalogs it: which chips and boards exist on the workbench, and the five configs that group them by processor core and USB path.
Why you need this
WiFi with a router is the right tool when a gadget needs the internet. Often it does not: a keyboard on one board needs to reach a screen on another across the desk, or a sensor needs to show its reading on a phone in your hand. Joining a network for that is slow, needs a password, and fails when the router is off. This page covers the two router-free links in stage 5 of the pipeline on the hub, "Go wireless": ESP-NOW for board to board, and Bluetooth Low Energy (BLE) for board to phone.
The idea
ESP-NOW
ESP-NOW is Espressif's protocol for sending short messages straight from one ESP32's WiFi radio to another's. There is no access point, no IP address, no connection. A message is addressed to a MAC address, the 6-byte hardware address every radio has, written like AA:BB:CC:DD:EE:FF. The special address FF:FF:FF:FF:FF:FF is broadcast: every ESP-NOW board in range on the same channel hears it.
The facts that shape a design, from Espressif's ESP-IDF ESP-NOW guide and MicroPython's espnow documentation:
| Fact | Value |
|---|---|
| Largest message, ESP-NOW v1 | 250 bytes |
| Largest message, ESP-NOW v2 | 1470 bytes |
| Registered peers | up to 20 |
| Peer needed to send | yes: register the address first |
| Peer needed to receive unencrypted messages | no |
| Channel | sender and receiver must be on the same WiFi channel |
A peer is an address you register before sending to it. Because receiving needs no registration, a board can hear broadcasts from boards it has never met, which is how discovery works.
The channel rule is the one that bites. ESP-NOW shares the WiFi radio, and the radio sits on one channel at a time. The author's knowledge base notes that fresh, unconnected boards default to channel 1, so two new boards just work; but if one board joins a router on channel 6, the other must move to channel 6 too, or they stop hearing each other.
On the air, each message travels inside an ordinary WiFi management frame. Espressif's guide gives the layout:
| Field | Bytes |
|---|---|
| MAC header | 24 |
| Category code (127, vendor specific) | 1 |
| Organization identifier (0x18FE34, Espressif) | 3 |
| Random values | 4 |
| Vendor-specific content | 7 plus your message |
| Frame check sequence | 4 |
Bluetooth Low Energy
Bluetooth LE is a separate radio protocol designed for small, infrequent messages at low power, and it is what phones use to talk to fitness bands and sensors. It works in two stages:
- Advertising: the board repeatedly broadcasts a short packet saying "I exist", with its name and what it offers. A phone scanning nearby lists it. MicroPython's official BLE example limits this advertising payload to 31 bytes.
- Connected, with GATT: once a phone connects, the board acts as a GATT server (generic attribute profile). It offers services, groups of related values, and each service holds characteristics, single values a phone can read, write, or ask to be notified about when they change. Each is named by a UUID, a 16-bit or 128-bit identifier.
Which chips have which radio
From the author's board notes:
| Chip | Boards | WiFi and ESP-NOW | Bluetooth |
|---|---|---|---|
| ESP32 (classic) | CYD, CYDE, WROOM-32 DevKit | yes, 2.4 GHz | Bluetooth Classic and BLE |
| ESP32-S3 | YD-ESP32-S3, Feather, Super Mini | yes, 2.4 GHz | BLE only, no Classic |
| ESP32-C6 | C6-DevKitM clone | yes, WiFi 6 | BLE (Bluetooth 5) only, plus IEEE 802.15.4 |
| ESP32-P4 | Waveshare P4 Core DevKit | none | none |
The P4 has no radio at all, so neither ESP-NOW nor BLE runs on it. Bluetooth Classic is the older, higher-bandwidth Bluetooth used by audio gear; only the classic ESP32 has it.
Worked example
The author's ESP_32_Keyboard project is a USB keyboard bridge. A YD-ESP32-S3 reads a USB keyboard and sends each key report over ESP-NOW; a CYD across the desk receives it and draws the key on its screen. Both halves have run on real hardware.
The sender, in C with ESP-IDF
This excerpt is from ESP_32_Keyboard's YD host firmware (C with ESP-IDF, hardware-proven). When the USB keyboard reports a change, the code copies at most 8 bytes, applies the user's key remapping, and sends.
uint8_t remapped[8];
int send_len = (length > 8) ? 8 : length;
memcpy(remapped, data, send_len);
keymap_apply(remapped, send_len);
/* Broadcast remapped HID report via ESP-NOW to all peers (CYD + Device YD) */
esp_now_send(NULL, remapped, send_len);
Passing NULL as the address sends to every registered peer. Elsewhere the same firmware pins its radio to channel 1 so the CYDs can hear it.
The receiver, in MicroPython
This excerpt is from ESP_32_Keyboard's CYD display main.py (MicroPython, hardware-proven). The sender's real address is replaced with a placeholder.
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
enow = espnow.ESPNow()
enow.active(True)
YD_MAC = b'\xaa\xbb\xcc\xdd\xee\xff' # the sender's MAC (placeholder)
enow.add_peer(YD_MAC)
...
while True:
host, msg = enow.irecv(100) # 100ms timeout so hold timer can fire
if msg and len(msg) >= 8:
keys, codes, mods = decode_report(msg)
The WiFi interface is switched on but never joins a network: ESP-NOW only needs the radio. irecv(100) waits up to 100 ms for a message and returns the sender's address and the message bytes.
One keypress, byte by byte
The message is a USB HID boot keyboard report: 8 bytes, which the receiver's decoder reads as a modifier byte (data[0]), one unused byte, then up to six key codes (data[2] to data[7]). In the decoder's tables, modifier bit 0x02 is left Shift and key code 0x04 is A.
Press Shift and A together, then let go:
| Event | Message bytes | Decoded |
|---|---|---|
| press | 02 00 04 00 00 00 00 00 |
mods: LShft; keys: A |
| release | 00 00 00 00 00 00 00 00 |
nothing held |
Each message is 8 bytes of a possible 250, about 3%. On the air, each is a frame of bytes, so one keypress costs two frames, 102 bytes in all.
The round trip for that keypress:
- The keyboard sends its report to the YD over USB.
- The YD remaps it and calls
esp_now_send. One frame crosses the room. - The CYD's
irecvreturns at once with the 8 bytes, and the screen redraws "A" with "LShft". - On release, the all-zero report arrives and starts a 500 ms hold timer (
HOLD_MS = 500) so the key stays readable. - The timer is checked each time
irecvreturns, which is at least every 100 ms, so the screen clears between 500 and 600 ms after release.
There is no acknowledgment in this design. If a release frame is lost, the CYD keeps showing the key until the next report arrives.
A BLE peripheral, in MicroPython
None of the author's projects uses BLE yet. This sketch is adapted from MicroPython's official bluetooth documentation and its advertising example (not hardware-tested). It offers one readable, notifiable characteristic and advertises a name. The two UUIDs are made-up placeholders; generate your own.
import bluetooth
from micropython import const
_FLAG_READ = const(0x0002)
_FLAG_NOTIFY = const(0x0010)
SVC = bluetooth.UUID("12345678-0000-4000-8000-00000000aaaa")
CHR = bluetooth.UUID("12345678-0000-4000-8000-00000000aaab")
ble = bluetooth.BLE()
ble.active(True)
((h_value,),) = ble.gatts_register_services(((SVC, ((CHR, _FLAG_READ | _FLAG_NOTIFY),)),))
ble.gatts_write(h_value, b"23.0")
name = b"ESP32"
adv = b"\x02\x01\x06" + bytes((len(name) + 1, 0x09)) + name
ble.gap_advertise(500_000, adv) # advertise every 500 ms
The advertising payload is a list of small records, each a length byte, a type byte, and data. 02 01 06 is the flags record (type 0x01, value 0x06, which the official example builds as 0x02 "general discoverable" plus 0x04 "classic Bluetooth not supported"). Then comes the name record, type 0x09: length , then the 5 bytes of ESP32. Total: bytes of the 31 allowed.
In an ESP32 project
ESP-NOW fits gadgets that talk among themselves: the keyboard bridge, and the author's notes describe a presence pattern where each board broadcasts a heartbeat about every 300 ms and treats a board as gone after a silence timeout, the only drop signal there is, since a board that loses power cannot say goodbye. The author's TeachingMachineCYD pairs with the keyboard host by listening for broadcast beacons that start with the bytes ECC\x01 and registering the sender as a peer, so no address is ever typed in. BLE fits gadgets that talk to a phone. Both can run alongside WiFi, but ESP-NOW then follows the router's channel.
Common mistakes
- Mismatched channels. Symptom: boards that worked on the bench go silent once one joins a router. Pin every board to the router's channel.
- Sending to an unregistered address. Symptom: an error on send. Register the peer first; receiving needs no registration.
- Messages over the limit. Symptom: the send fails. Keep ESP-NOW v1 messages to 250 bytes.
- Expecting delivery on broadcast. Symptom: an occasional missed event. Broadcast frames get no acknowledgment; design so the next message repairs the state.
- Hard-coding a unit's MAC. Symptom: replacing a board breaks the link. Discover peers with a broadcast beacon instead.
- Trying BLE on a P4, or Bluetooth Classic on an S3 or C6. Symptom: the radio never appears. Check the radio table.
Cost
Both protocols live in the chip's radio firmware, so your own code is small: the CYD receiver is under 200 lines including its display code. The cost is RAM and flash for the radio stacks, which MicroPython's standard ESP32 firmware already includes (the author's notes say espnow is built in, no custom build). ESP-NOW is fast to start: no join, no DHCP, no handshake, so the first message can go out as soon as the radio is on. Each 8-byte key report costs a 51-byte frame. BLE costs more setup (advertising, connection, service registration) but lets any phone connect without custom hardware. Power: an always-listening receiver keeps its radio on, which matters on battery.
Going further
- Networking basics, for what ESP-NOW leaves out.
- WiFi and MQTT, for when you do need a router and the internet.
- Espressif's ESP-NOW guide in the ESP-IDF documentation, including ESP-NOW v2 and encrypted peers.
- MicroPython's
bluetoothmodule documentation and its BLE examples (advertising, a temperature sensor, a UART over BLE). - The USB HID specification's boot keyboard report, for the full key code table.
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython