espnow.py, espnow_conn_v2.py,
espnow_msg.py, aioespnow.py, and the peer_* modules.
Why ESP-NOW
- No infrastructure: works anywhere, no WiFi network or internet.
- Low latency, low power: short frames, radio can sleep between them.
- Direct + broadcast: unicast to a bonded peer, or broadcast for discovery and demi-god commands.
Application frame
Every ESP-NOW frame — unicast and broadcast alike — begins with a fixed 2-byte SyncWord followed by a category/command header, then a payload whose layout is selected by the(cat_id, cmd_id) pair (see message format).
Validation is SyncWord match + length only — there is no CRC or checksum on the ESP-NOW
frame. A receiver rejects any frame shorter than 4 bytes or not beginning with
0xA7 0x74, then validates the payload against the per-(cat,cmd) struct size. There is
no in-band length field: the receiver uses the ESP-NOW MAC frame length plus the expected
struct size.
Both
Invalid Checksum value for: {} and
Invalid payload size for: {} | expected: {}, received: {} | Total Errs: {} / {} belong to
the u-blox UBX GNSS parser (ubx_gnss, a Fletcher CK_A/CK_B check), not the ESP-NOW path
— the only validation log the ESP-NOW module (espnow_conn_v2) emits is
Invalid ESP-NOW message SyncWord or length. A custom ESP-NOW client computes no checksum —
prepend 0xA7 0x74, set cat_id/cmd_id, and append the payload.Channel
The mesh operates on a fixed home channel — channel 6 (self.channel = 6, hardcoded in
espnow_conn_v2.__init__; the only write to self.channel in the module — Confirmed). A peer
on a different channel is rejected outright:
MODE_ESP_NOW_WIFI, MODE_ESP_NOW_WIFI_BLE) so ESP-NOW runs alongside them
rather than being switched off. Because every peer must stay on the shared home channel,
any WiFi activity that retunes the radio to another channel (for example a WiFi firmware
download) would interrupt mesh delivery until the radio returns to the home channel.
Long-range PHY
In the ESP-NOW-only and ESP-NOW+BLE run-modes the radio PHY is Espressif Long Range (WIFI_PROTOCOL_LR, protocol bit 8), trading throughput for range across a large venue.
The Wi-Fi-coexist modes (MODE_ESP_NOW_WIFI, MODE_ESP_NOW_WIFI_BLE) fall back to standard
802.11 B/G/N (protocol bit 7). This means a non-Espressif NIC — or an ESP32 not put into LR
mode — cannot exchange frames while the totem runs the common ESP-NOW-only / ESP-NOW+BLE
modes: a client must be an Espressif device configured with WIFI_PROTOCOL_LR on channel 6.
The LR data rate is set on the ESP-NOW object, not via wlan.config(lorate=...):
EspConn.power_on and EspConn.recover both call self.e.config(rate=41), and 41 = 0x29 =
WIFI_PHY_RATE_LORA_250K. v5.0.3’s WiFi-kick path re-applies it after restarting the driver.
A client should use LR 250K to match. (PHY and rate: Confirmed in v5.0.2 and v5.0.3.
Earlier versions of this page said the IDF default of 500K applied; that was wrong.)
Peers & bonding
“Bonding” here is a Totem-to-Totem relationship (distinct from BLE bonding with the phone). A bonded set of Totems is a friend group that syncs positions and messages.
Bonding events drive LED animations:
anim_add_peer, anim_bonding_ctdwn
(countdown), anim_peer_del_ctdwn, add_peer_animation.
Smart Group (auto-bond join)
Auto-bonding uses a broadcast Smart Group join protocol. A host broadcasts a Smart Group command that carries the sender’s RSSI; a receiver withinSMART_GROUP_RSSI (-45 dBm)
registers the sender as a peer to bond to, then the two sides exchange bond bursts.
Confirmed strings from the send/receive path:
Incorrect auto-bond group, ignore request). Broadcast
discovery itself rides gen_public_chirp / advertise_burst (Created public chirp).
A cancel frame (instruction_id == -1) ends the group. Since v5.0.3 it also does so when only
the group UID matches (not just while smart_grp_ticks is set), and it also resets
auto_bond_timeout and client_exit_auto_bond.
Bonding flow
If a bond request arrives from a device that is already a peer and was seen within 6 s (Both devices already bonded), v5.0.3 answers it before cb_already_bonded. The answer is
five unicast peer ACKs (gen_peer_msg(cmd_id=1, is_ack=1, is_return=True), cat 6) sent 80 ms
apart, so the requester stops retrying.
Encryption — there is none
All ESP-NOW traffic is cleartext — broadcast and unicast. No PMK is ever programmed and no LMK is ever set, so the ESP-NOW link layer runs entirely unencrypted. This is confirmed, not inferred:- The ROM ESP-NOW C-module exposes
set_pmk,lmk, andencrypt, but those qstrs appear in no frozen module’sqstr_table— no Python code ever references them. add_peer(mac)is always called with a single positional argument, solmkdefaults toNoneandencryptdefaults toFalse. The concept of ESP-NOW encryption exists in the ROM module but is never used.- There is also no application-layer authentication on the ESP-NOW path — no token, HMAC, challenge/response, or signature.
The IDF strings
PMK is NULL, set lmk fail, Encryption Failed, and
Do not support encryption for multicast address are the ESP-IDF ESP-NOW layer’s own
messages; their presence in the image does not mean the app uses encryption. The app
never sets a PMK or LMK.Peer sync & relay
Once bonded, Totems exchange Peer Sync messages —(0x06, 0x07) — plus Peer
Ping liveness. Delivery is buffered and relayed:
Peer updates can also be pushed to the phone:
Adding Peer: {} to BLE outbox, and are
ACKed as BLE ACK Peer Sync / BLE ACK Peer Ping.
Transmit back-pressure (v5.0.3)
v5.0.2 sent an all-peer message as one driver call,e.send(None, msg). v5.0.3 changes
send_now for all-peer and explicit-MAC sends, and adds a supervisor:
- All-peer sends (
macNone /'all', used by_send_outbox) are a unicast loop over the non-POI peers. Each send goes to at most_tx_free()peers, and the start index rotates so peers skipped last time go first. - Free-buffer estimate:
_tx_free()is32 − 4 − pending_tx(). It is 0 for 2 s after the driver refuses a frame withESP_ERR_ESPNOW_NO_MEM.pending_tx()istx_pkts − tx_responsesfrome.stats(), relative to a rebased baseline. - Dropping sends: with no free buffers, explicit-MAC and broadcast sends, including
demi-god broadcasts, are dropped locally (
tx_skipped). - Phantom-pending probe:
_tx_probechecks whether a stuck pending count is real. It triggers at ≥ 20 pending for 30 s, at most once per 120 s. NO_MEMis back-pressure, not a fault. v5.0.2 loggedESP_ERR_ESPNOW_NO_MEMand setis_silent_reboot. v5.0.3 only records the refusal (tx_refused) and backs off.- Supervisor: the comms task now runs under
communicate_supervised. It restartscommunicate_v2after aRuntimeError/OSError(communicate_v2 died; restarting). - WiFi kick coordination: the task waits out the new BLE-side WiFi driver kick
(
is_kicking,evt_kick_idle) before light-sleeping or re-activating the WLAN.
Relay & multi-hop
The Unity Mesh is a flooding mesh with slotted relay suppression, not tree routing. A node re-broadcasts frames it hears (_relay_frame) up to max_hops, gated by RSSI
distance so it relays for distant peers and suppresses for near ones (MESH_MIN_HOP_RANGE,
MESH_RELAY_MIN_DIST). Before relaying it waits a randomized slot delay
(MESH_SLOTS_PER_NODE, rand_tail, tail_expire_sec); if it hears another node relay
first it backs off instead of duplicating:
origin_mesh, originator, mesh_honored,
mesh_relayed).
v5.0.3 sheds relay load. handle_mesh_msg drops mesh frames whose originator is not a
bonded peer in two cases, and such frames are neither processed nor relayed:
-9(relay_skip_drop): the receiver is overloaded (the driver’srx_droppedcounter grew since the last cycle).-10(relay_skip_pool):load_fps ≥ 4and at least 8 frames are pending TX.
project_data module (and the gen_mesh_msg
default for max_hops); all Confirmed from the disassembly:
What rides the mesh
- Positions & headings of bonded friends (for the “navigate to friend” feature).
- Chat messages (
chat_msg.py,chat_inbox_unread). - Peer bond details (
Downloading Peer Details). - Demi-god broadcast commands (see next page).
- Nav-log rules distributed via demi-god broadcast (
demigod_gen_add_nav_log_rule). - RTC / time-of-day sync — nodes distribute the clock over the mesh (
RTC set via Peer's itod,gen_peer_sync).