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. 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):
  1. 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]).
  2. len(frame) >= 4 and frame[0:2] == b'\xa7\x74', else Invalid ESP-NOW message SyncWord or length.
  3. (cat_id, cmd_id) must be a key of TOTEM_MSG_MAP, else Unknown Message with body x{:02x},x{:02x}.
  4. struct.unpack(fmt, frame[2:calcsize(fmt)+2]) — the slice is exactly sized, so a frame shorter than calcsize(fmt) + 2 raises 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:
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

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:
  1. a broadcast gen_peer_msg(cmd_id=1, is_ack=0) (ack byte at offset 18 = 0) until it has picked a partner, then
  2. a unicast gen_peer_msg(cmd_id=1, is_ack=1) to that partner.
The receiver (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):
  1. 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.
  2. A client that is pairing and hears a beacon at RSSI ≥ SMART_GROUP_RSSI (−45 dBm) — or one whose smart_grp_uid already 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 to max_bonds) and each join extends the window by up to 12 s. A client that finds its own MAC in the member list sets smart_grp_uid and waits.
  3. 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 runs peer_management.auto_bond_to_peers, which awaits delete_all_peers()every existing peer is deleted — and then creates a fresh Peer for every other member, with the position and colour the beacon carried. Wrong-group frames are ignored (Incorrect auto-bond group, ignore request); a −1 frame 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).
Hosting a Smart Group near other people’s Totems can wipe their friend lists: a member that accepts instruction 3 deletes every peer it had. Only host with Totems you own.

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 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_peersConfirmed, 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.
Speed 3’s period is real — the timer still fires every 4 s — but 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 windows communicate_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 < 100Not sleeping due radio needing to turn on soon; or
  • modes.evt_rtc_ready.is_set() and modes.rtc_method == 1 and ticks_diff(modes.next_rtc_sync, ticks_ms()) < -15000Block sleep for GNSS RTC Sync. All three must hold: a clock borrowed from a peer is rtc_method == 2 and never blocks.
Inside smart_sleep(sleep_duration_ms, speed_id):
  • sleep_duration_ms < 10 returns immediately.
  • The per-call chunk is COMM_SPEED[speed_id][2], trimmed to remaining - 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 -1 in both slots, so they never sleep at all.
  • The expected receive times of bonded peers (peer.next_msg, logged as RX windows: {}) are collected first; stale entries whose time has already passed are cleared.
  • With a clock and a modes.next_gnss_ticks set, 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 < 2 with a satellite signal; BLE is active; a peer burst is running; or the watchdog manager is blocking tasks or inactive.
Waking logs 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 (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.

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_coro wakes 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 waits MESH_SEND_FREQ_MS (30 s) before the next request, and at most 999 requests are sent per peer. The requester’s post_comms_check sends 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 at max_hops (99), after its expiry (origin time + 120 s), or when the relayer is within MESH_RELAY_MIN_DIST (10 m) of the last hop.
  • Load shedding. handle_mesh_msg drops UIDs already in parser.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 than relay_min_dist (-8), or outside the node’s slots (-6: uid % (active_nodes × MESH_SLOTS_PER_NODE) not in node_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.
A Totem with no GNSS fix ignores the mesh entirely (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 does not ride the mesh 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.