Skip to main content
The Unity Mesh Network is Totem-to-Totem communication built on ESP-NOW — Espressif’s connectionless 2.4 GHz protocol that sends frames directly to a peer MAC without an access point. Implemented in 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:
WiFi and BLE coexist with the mesh on this one radio — the firmware exposes combined run-modes (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 within SMART_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:
Wrong-group joins are ignored (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, and encrypt, but those qstrs appear in no frozen module’s qstr_table — no Python code ever references them.
  • add_peer(mac) is always called with a single positional argument, so lmk defaults to None and encrypt defaults to False. 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.
There is no confidentiality or authenticity on the mesh. Any Espressif device on channel 6 in Long-Range mode can read every peer sync, position, and chat message and can inject valid frames. The only gates are RSSI proximity (for bonding) and msg-UID de-duplication — neither is a security control.
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 (mac None / '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() is 32 − 4 − pending_tx(). It is 0 for 2 s after the driver refuses a frame with ESP_ERR_ESPNOW_NO_MEM. pending_tx() is tx_pkts − tx_responses from e.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_probe checks whether a stuck pending count is real. It triggers at ≥ 20 pending for 30 s, at most once per 120 s.
  • NO_MEM is back-pressure, not a fault. v5.0.2 logged ESP_ERR_ESPNOW_NO_MEM and set is_silent_reboot. v5.0.3 only records the refusal (tx_refused) and backs off.
  • Supervisor: the comms task now runs under communicate_supervised. It restarts communicate_v2 after a RuntimeError/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:
When too few nodes are nearby, slotting is disabled and every frame is relayed. Origin and relay state is tracked per frame (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’s rx_dropped counter grew since the last cycle).
  • -10 (relay_skip_pool): load_fps ≥ 4 and at least 8 frames are pending TX.
The mesh tunables are resolved from the frozen 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).