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. There is no in-band length field: the receiver uses the ESP-NOW MAC frame length
plus the expected struct size. The checks, in order (Confirmed,
EspConn.recv at
espnow_conn_v2.dis:6560-6890 and Parser.aread at :7444-7650):
- The driver must have handed up both a MAC and a payload, and the MAC must be in
e.peers_table— MicroPython’s ESP-NOW module puts every sender there on receipt, and it is also where the per-frame RSSI comes from (peers_table[mac][0]). len(frame) >= 4andframe[0:2] == b'\xa7\x74', elseInvalid ESP-NOW message SyncWord or length.(cat_id, cmd_id)must be a key ofTOTEM_MSG_MAP, elseUnknown Messagewithbodyx{:02x},x{:02x}.struct.unpack(fmt, frame[2:calcsize(fmt)+2])— the slice is exactly sized, so a frame shorter thancalcsize(fmt) + 2raises inside the parser task rather than being rejected politely. This is the “length check”: there is no upper bound, and trailing bytes are ignored.
recv also drops some frames before step 3:
(2,0) frames that pass never reach aread at all: recv calls handle_mesh_msg and
Parser.compass_mesh inline, each wrapped in try/except Exception (Could not parse mesh,
Mesh processing failed). Everything else is dispatched to parser.aread as a task.
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
The mesh always runs on Espressif Long Range (WIFI_PROTOCOL_LR, protocol bitmap 0x08,
LR only). EspConn.check_mode knows three (pm, protocol) pairs, and v5.0.3 only uses the
two LR ones: mode 0 (PM_NONE, LR) for ESP-NOW alone, and mode 2 (PM_PERFORMANCE, LR)
while BLE is active. Mode 1 (802.11 b/g/n) has no callers. A non-Espressif NIC, or an ESP32
not put into LR mode, therefore cannot exchange frames with a Totem: a client must be an
Espressif device configured with WIFI_PROTOCOL_LR on channel 6. power_on also sets
txpower=21 (dBm; the driver caps it at 20).
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. power_on also sets self.e.config(rxbuf=3072) before activating
the radio. v5.0.3’s WiFi-kick path re-applies the rate after restarting the driver.
A client should use LR 250K to match. (PHY and rate: Confirmed in v5.0.2 and v5.0.3, and
on hardware: an ESP32 set up this way bonds with a 5.0.3 Totem, see
ESP32 emulator. 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.
Bonding events drive LED animations:
anim_add_peer, anim_bonding_ctdwn
(countdown), anim_peer_del_ctdwn, add_peer_animation.
Pairing (P2P bond)
The normal bond between two Totems is a handshake of category 0, command 1 peer frames (the same 108-byte frame as the peer status, see message format). It is Confirmed from the bytecode (Compass.start_pairing, Compass.pair_nearby, Parser._peer) and on hardware.
Trigger. A long hold on the Touch Crystal. TouchButton defines the three thresholds —
_short_hold_ms = 360, _long_hold_ms = 1200, _ex_hold_hold_ms = 7200
(touch_button_v2.dis:293-301) — and Compass.touch_button_cb, for touch features 1 and 3,
binds cb_short_hold to start_bonding_countdown, cb_long_hold to start_pairing and
cb_ex_long_hold to start_smart_group (it re-asserts set_long_hold_ms(1200) first; the
peer-management feature 2 raises it to 3000 instead). The owners of both Totems hold them
within the same few seconds. start_pairing refuses while a Smart Group is active or when
the Totem already has max_bonds (8) peers, and it opens a 6 s window: the signature is
start_pairing(is_init=True, timeout_sec=6) and the body is
asyncio.wait_for(pair_nearby(), timeout_sec) (compass.dis:1194-1199). A timeout logs
Pairing Timed Out. All Confirmed.
Handshake. While pairing, each Totem runs pair_nearby, which every randrange(50, 100) ms
sends:
- a broadcast
gen_peer_msg(cmd_id=1, is_ack=0)(ack byte at offset 18 = 0) until it has picked a partner, then - a unicast
gen_peer_msg(cmd_id=1, is_ack=1)to that partner.
Parser._peer) acts on a command-1 frame only while it is pairing itself and
only at RSSI ≥ BONDING_RSSI (−25 dBm), i.e. with the two devices practically touching:
When the window closes (
cancel_pairing), a partner that never sent an ack-1 frame
(temp_bond still set) is deleted again: Deleting Peer with failed bond: {}. After a
completed first bond with the phone connected, the Totem runs ble_first_bond_burst:
send_burst(interval_ms=300, burst_duration_ms=6000), so 20 frames over 6 s — Confirmed.
There are no keys and no challenge: the bond is the two entries in config.peers, keyed by the
ESP-NOW source MAC (the STA MAC, machine.unique_id()). The peer-frame target_mac field is
always zero in 5.0.3 and is never read. A (0,2) unbond notice is sent to a deleted peer, but
5.0.3 receivers ignore it.
Smart Group (auto-bond)
A Smart Group bonds a whole group at once. It uses category 7 (see message format):- The host (extra-long hold) picks
smart_grp_uid = randint(1, 65534)and broadcasts a(7,0)beacon with instruction 1 every 333 ms for up to 40 s. The beacon carries the host’s position, the group UID, the time left and the member list (MAC + position + colour per member). It does not carry RSSI: receivers measure it. - A client that is pairing and hears a beacon at RSSI ≥
SMART_GROUP_RSSI(−45 dBm) — or one whosesmart_grp_uidalready matches, at any RSSI — broadcasts a(7,1)join (join flag +1) twice, 200 ms apart (Sent Smart Group join cmd). The host adds it (up tomax_bonds) and each join extends the window by up to 12 s. A client that finds its own MAC in the member list setssmart_grp_uidand waits. - The host finishes with instruction 3 (shuffled colours) or −1 (abandon), each as a
300 ms × 1.6 s
send_burst. On instruction 3, every member runspeer_management.auto_bond_to_peers, which awaitsdelete_all_peers()— every existing peer is deleted — and then creates a freshPeerfor every other member, with the position and colour the beacon carried. Wrong-group frames are ignored (Incorrect auto-bond group, ignore request); a−1frame cancels on a UID match, or whenever the receiver is not in a group at all.
Parser._smart_group keeps a second, tighter RSSI mark: a beacon at RSSI ≥ −35 (a
hardcoded literal, not SMART_GROUP_RSSI) also stamps modes.smart_grp_last_nearby, which
is how the Totem knows the host is still right next to it. Confirmed
(espnow_conn_v2.dis:10118-10126).
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 status and radio windows
Bonded Totems keep each other up to date with the peer status frame,(0,0) from
gen_peer_msg(cmd_id=0): position, heading, SOS, orientation, clock, battery, version and
name (field table in message format). It is
not a broadcast: send_now(None, …) loops over the bonded, non-POI peers and unicasts one
copy to each. An unbonded Totem therefore transmits nothing except while pairing. A second
copy goes out when config.furthest_peer > 30 (metres) and recovery_count == 0, i.e.
the radio has not been recovered this session — Confirmed, _send_outbox.
EspConn.communicate_v2 opens a radio window once per period and sends the status
75 ms after the window’s nominal open tick (ticks_add(hpt.ticks_radio_on, 75) in
ping_peers — Confirmed, espnow_conn_v2.dis:4305-4310; the scheduler then shaves 3 ms off the wait). With a clock the windows are
aligned to the wall clock (radio_timer: the next multiple of the period since the start of
the minute), so all GNSS-synced Totems transmit in the same slots.
COMM_SPEED maps a speed id to (mpm, radio_on_ms, ls_chunk_ms, ls_reserve_ms); the period
is 60 / mpm seconds. The literal is Confirmed at espnow_conn_v2.dis:931-962, and the
selection below from communicate_v2 at :2967-3040.
The order matters: 8 overrides 2 overrides 1, and 6 overrides both 3 and 4. Only the upright
branches set
is_scheduled (1, 2, 3, 4, 6, 8); the lying-flat ones (0 and 5) do not, and only
a scheduled window can light-sleep.
COMM_SPEED also defines speed 7 (30, 200, 50, 50) — 2 s, 200 ms — and speed 10
(15, 200, 75, 50) — 4 s, 200 ms. Neither is reachable: speed_id is a local of
communicate_v2, the block above is the only place it is assigned, and COMM_SPEED is read
nowhere else in the image (espnow_conn_v2.dis:3197, :3202, :3567, :3572 are its only
four reads). Confirmed.radio_on_ms is 0, so
communicate_v2 calls power_off() instead of scheduling ping_peers. A Totem upright with
no clock and no peers therefore transmits nothing.
No status goes out while the Totem is in the bonding UI: ping_peers awaits
modes.evt_bonded and returns early (setting evt_comms_sent) when it is clear.
Light sleep between windows
Between radio windowscommunicate_v2 can put the SoC into machine.lightsleep. All of
this is Confirmed from espnow_conn_v2.dis (communicate_v2 at :3330-3470,
smart_sleep at :3556-4198).
The budget is hpt.ms_until_radio_on - 100, or a flat 3500 ms when radio_on_ms is 0
(speed 3). Sleep is then refused outright when, with the window scheduled and
radio_on_ms > 0:
hpt.ms_until_radio_on < 100—Not sleeping due radio needing to turn on soon; ormodes.evt_rtc_ready.is_set()andmodes.rtc_method == 1andticks_diff(modes.next_rtc_sync, ticks_ms()) < -15000—Block sleep for GNSS RTC Sync. All three must hold: a clock borrowed from a peer isrtc_method == 2and never blocks.
smart_sleep(sleep_duration_ms, speed_id):
sleep_duration_ms < 10returns immediately.- The per-call chunk is
COMM_SPEED[speed_id][2], trimmed toremaining - COMM_SPEED[speed_id][3]when the window is closer than that. A chunk below 50 ms abandons the sleep. Speeds 0, 4 and 5 carry-1in both slots, so they never sleep at all. - The expected receive times of bonded peers (
peer.next_msg, logged asRX windows: {}) are collected first; stale entries whose time has already passed are cleared. - With a clock and a
modes.next_gnss_ticksset, the time to the next GNSS message decides: inside −120 ms … +85 ms of it the call yields to the scheduler and does not sleep; further out, the chunk is cut to “time until the message, minus 120 ms”, and a negative result abandons the sleep. - Sleep is also skipped, with the missed milliseconds accumulated into
modes.dev_ls_missed, when: the VFS is busy; the Totem is lying flat (evt_orien_horizontal); GNSS messages are being missed (is_gnss_msg_miss) or are invalid (Lightsleep skipped due to invalid messages);gnss_data.valid_time_counter < 2with a satellite signal; BLE is active; a peer burst is running; or the watchdog manager is blocking tasks or inactive.
Awoke from lightsleep | Slept for: {} of {} | Total Sleep: {} | sleep duty: {:.3f}.
Clock sync. The status frame carries itod (ms since midnight) and the Unix time only
when the sender’s clock came from GNSS (rtc_method == 1); otherwise both are −1. A Totem
without a clock takes the time from the first such frame (RTC set via Peer's itod, not
earlier than 30 s after boot) and then sets rtc_method = 2, so it does not pass a borrowed
clock on.
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. (0x06, 0x07) Peer Sync belongs to that
BLE registry; on the air the peer frames are category 0.
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.
Locate requests & relay
The only multi-hop frame is the 45-byte locate frame(2,0) from gen_mesh_msg (field
table in message format). It is a flood with
de-duplication, not a routed mesh:
- Request.
Compass.one_sec_mesh_corowakes 200 ms before each 4-second boundary. In the device’s own slot (rtc.sec % 5 == mesh_grp,mesh_grp = sha256(mac)[:4] % 5) and with a GNSS fix, it looks for bonded peers that went stale (no position for 2 ×clamp(distance / 75 m × 60, 60, 600)s) or are only reachable through the mesh. It then queues a broadcast locate request (is_ack= 1) for the next radio window. Each stale peer then waitsMESH_SEND_FREQ_MS(30 s) before the next request, and at most 999 requests are sent per peer. The requester’spost_comms_checksends it again with a new UID while nobody relays it back. - Reply. A Totem that hears a request from one of its own bonded peers, and has not heard
that peer directly in the last 60 s, broadcasts its own locate frame (
is_ack= 0) once, plus one repeat with the same UID in a later window. It replies at most once a minute. - Relay. Every Totem with a fix and a clock relays locate frames it has not seen
(
_relay_frame): the same bytes, hop count at offset 29 plus one, and its own position written over the last-hop coordinates at offset 35. A frame is not relayed atmax_hops(99), after its expiry (origin time + 120 s), or when the relayer is withinMESH_RELAY_MIN_DIST(10 m) of the last hop. - Load shedding.
handle_mesh_msgdrops UIDs already inparser.recent(-5), frames received before the clock is set (-2) and, for origins that are not bonded peers, frames that arrive while the node is overloaded (-9,-10), past the ack/relay budgets (-3,-4), from a last hop closer thanrelay_min_dist(-8), or outside the node’s slots (-6:uid % (active_nodes × MESH_SLOTS_PER_NODE)not innode_slots; with fewer than 6 nearby nodes every frame is relayed). - Echo. An originator that hears its own UID relayed back counts it as honoured (
Relay honored by another device. Waiting: {}ms before re-using mesh to find Peer) and backs off.
recv drops (2,0) frames when
gnss_data.location is unset).
The mesh tunables are resolved from the frozen project_data module (and the gen_mesh_msg
defaults); all Confirmed from the disassembly:
What rides the mesh
- Positions, headings, SOS and battery of bonded friends: the
(0,0)status, unicast in every radio window, and the(2,0)locate flood for friends out of direct range. - Clock from a GNSS-synced peer (
RTC set via Peer's itod). - Bonds: the
(0,1)pairing handshake and the(7,x)Smart Group. - Demi-god broadcast commands (see next page). The add-nav-log-rule command
(1,2)is a no-op in 5.0.3.
chat_msg.Messenger is imported by no module, and
EspConn.recv drops (5,0) frames. (3,1)/(3,2) crowd-animation frames have a builder but
no caller and no receiver. gen_public_chirp, advertise_burst, add_new_bond and
gen_peer_sync are BLE-side (ble_manager) builders, not ESP-NOW frames.