> ## Documentation Index
> Fetch the complete documentation index at: https://totem-cb8b3887.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Message format

> The (cat_id, cmd_id) model, the two format registries (BLE gen_* vs ESP-NOW TOTEM_MSG_MAP), chunking, and file transfer.

## Addressing: `(cat_id, cmd_id)`

Every application message carries a one-byte **category** and a one-byte **command**.
This tuple is used on both the BLE link and the ESP-NOW mesh — but **the same
`(cat_id, cmd_id)` maps to a different wire layout on each transport** (see
[Two format registries](#two-format-registries-do-not-conflate)).

```text theme={null}
┌────────┬────────┬───────────────────────────┐
│ cat_id │ cmd_id │ payload (struct-packed)   │
│ 1 byte │ 1 byte │ variable                  │
└────────┴────────┴───────────────────────────┘
```

Evidence: `[BLE] DataXfer cat_id: {} | cmd_id: {}`,
`[BLE] ConnStatus cat_id: {} | cmd_id: {} | len: {}`,
`A cmd_id must be provided for demi-god messages`.

## Two format registries — do not conflate

<Warning>
  `(cat_id, cmd_id)` is reused by **two transports with different struct layouts**. A
  BLE client MUST use the BLE `gen_*` formats below — the ESP-NOW `TOTEM_MSG_MAP` formats
  will decode a BLE frame into garbage.
</Warning>

| Registry                         | Where                            | What it describes                                                                                                   |
| -------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **BLE `gen_*` builders**         | `ble_manager.py` / `ble_core.py` | the GATT payloads the **phone reads/writes** — [BLE message formats](#ble-message-formats-gen_-what-a-client-reads) |
| **`TOTEM_MSG_MAP` / `EXTENDED`** | `espnow_conn_v2.py`              | the **ESP-NOW / mesh** peer-to-peer radio formats — [ESP-NOW registry](#esp-now-registry-totem_msg_map)             |

Proof they differ for the same key: BLE `gen_live_data` `(0x03, 0x01)` packs
`<bfi3fb4Bi3b2hbiffb3ibHBBb` (69 B), while `TOTEM_MSG_MAP (3,1)` = `<BBHH4b4BHHBb`
(20 B). Same key, different bytes. (**Confirmed**)

## Categories

Confirmed BLE `(cat_id, cmd_id)` pairs and their labels, taken directly from the
`send_data_v2` transmit logs:

| cat\_id | Category        | Confirmed commands                                | Meaning                                      |
| ------- | --------------- | ------------------------------------------------- | -------------------------------------------- |
| `0x01`  | **Static Data** | `(0x01, 0x02)`                                    | device config / identity that changes rarely |
| `0x03`  | **Live Data**   | `(0x03, 0x01)`                                    | real-time state (position, heading, battery) |
| `0x06`  | **Peer**        | `(0x06, 0x02)` per-peer, `(0x06, 0x07)` Peer Sync | peer list / peer sync between Totems         |
| —       | **Conn Status** | (handshake)                                       | connection readiness + mode negotiation      |

The receive path dispatches app→device commands on `cat_id`. Writes to `…-0002` go to
`recv_data_msgs`; writes to `…-0001` go to `recv_status_msgs`. The complete map, decoded from
both handlers (**Confirmed**; identical in v5.0.2 and v5.0.3 except where marked):

**Data characteristic (`…-0002`)**

| `(cat, cmd)` | Payload after the 2-byte header                                                                                                            | Effect                                                                                                                                                                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `(1,0)`      | —                                                                                                                                          | ack Static Data (`is_static_data_sent = True`, stops the legacy repeat)                                                                                                                                                                              |
| `(1,1)`      | —                                                                                                                                          | request Static Data; in half duplex also sets `evt_is_tx_owner`                                                                                                                                                                                      |
| `(2,0)`      | —                                                                                                                                          | forget the cached WiFi scan (acks the WiFi list)                                                                                                                                                                                                     |
| `(2,1)`      | —                                                                                                                                          | scan WiFi (`nearby_wifi`); result arrives as `(2,2)` + JSON list of SSIDs                                                                                                                                                                            |
| `(2,3)`      | JSON `{"nw": ssid, "join": key}`                                                                                                           | save the update WiFi network (`user-config.json`)                                                                                                                                                                                                    |
| `(2,5)`      | —                                                                                                                                          | demi-god "update nearby devices" broadcast (affects other Totems)                                                                                                                                                                                    |
| `(4,cmd)`    | `<bBbbbh` = ota\_cmd, flags, branch\_len, version\_len, 0, endpoint\_id; then branch, version                                              | `cb__start_ota`: saves `perform.ota` and soft-reboots into WiFi OTA (needs a charged battery)                                                                                                                                                        |
| `(5,3)`      | JSON `{"name": name}`                                                                                                                      | rename the device                                                                                                                                                                                                                                    |
| `(6,0)`      | —                                                                                                                                          | close the on-device peer-management UI                                                                                                                                                                                                               |
| `(6,1)`      | —                                                                                                                                          | request Peer Sync (`peer_cmd_id = 1`)                                                                                                                                                                                                                |
| `(6,3)`      | `mac(6)`, `<BBB` rgb, flags (bit0 delete, bit2 hidden)                                                                                     | edit or delete a peer / POI                                                                                                                                                                                                                          |
| `(6,5)`      | peer MAC as lowercase hex string                                                                                                           | open peer management on that peer                                                                                                                                                                                                                    |
| `(6,6)`      | frame length, `mac(6)`, `<ffbBBBhiiBBbbbhhiiib` (44 B), name                                                                               | add a peer / point of interest (`add_new_bond`; flags: sos, is\_poi, is\_sticky\_heading, is\_hidden, is\_locked). Index 6 (`h`) is the app's `azimuth`, which the firmware ignores; the trailing `bbb hh iii` are zeros from the app and never read |
| `(6,8)`      | none (all peers) or count, 0, `mac(6)`…                                                                                                    | queue Peer Pings (`Controller needs Peer details sent`)                                                                                                                                                                                              |
| `(6,9)`      | 1 byte, `mac(6)`, `<iff3bB` = unix, lat, lon, p\_acc, 0, speed, flags(bit0 sos)                                                            | update a peer's position from the cloud; v5.0.3 also marks the phone as online                                                                                                                                                                       |
| `(7,3)`      | `<b` 0, flags(bit0 persistent north, bit1 compass lock), flags(bit2 peer blink), flags, power byte (bits0-2: 1 eco, 2 normal, 0 unchanged) | compass settings (all at once)                                                                                                                                                                                                                       |
| `(10,x)`     | `<B5bBBB6B`; `buff[11:17]` target MAC                                                                                                      | developer options (only `dev_tgt_peer` is used)                                                                                                                                                                                                      |
| `(12,3)`     | `<ffbBih` = lat, lon, h\_acc, flags, unix, unix\_ms                                                                                        | phone GNSS fix and clock. flags: bit1 app UI closed, bit2 focused; **v5.0.3**: bit0 phone has internet (`ble_ticks_app_internet`, an upload gate)                                                                                                    |
| `(14,x)`     | `buff[2]` = 1 / 3 / 4                                                                                                                      | 1: reboot into WiFi OTA; 3 / 4: reboot into the BLE transfer service (`svc_ble_transfer`, mode 1 / 2)                                                                                                                                                |

**Conn-status characteristic (`…-0001`)**

| `(cat, cmd)` | Payload                                                                   | Effect                                                                     |
| ------------ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `(0,1)`      | `conn_mode, frame_schema_id[, caps]`                                      | Ready (see [BLE handshake](/protocols/ble#connstatus-ready-frame))         |
| `(0,3)`      | —                                                                         | graceful disconnect request                                                |
| `(2,3)`      | `<BBHbBBiHiB` upload reply                                                | **v5.0.3**: `FileUploader.on_header` (see [file transfer](#file-transfer)) |
| `(3,x)`      | bitfield: isActive, isFocused, isLocked, isUiClosed, isService, isDisconn | app runtime state; bit5 lets the device drop BLE on its own schedule       |
| `(4,3)`      | flags: bit1 grant, bit0 revoke                                            | half-duplex TX handoff                                                     |

The legacy transmit loop logs its sends per category (`Sending static data`,
`Sending BLE | DataTransfer (0x06, 0x07)`, …), and the half-duplex loop logs a confirmation per
record: `BLE ACK Static Data`, `BLE ACK Live Data`, `BLE ACK Peer Ping`, `BLE ACK Peer Sync`,
`BLE ACK Comms Handoff`.

## BLE message formats (`gen_*`) — what a client reads

Each `gen_*` builder writes `buff[0:2] = (cat_id, cmd_id)` then packs the payload from
live device state; the `LOAD_ATTR` names are the field semantics. These are the formats a
BLE client actually reads (**Confirmed** from the `struct.pack_into` opcode stream).

### Live Data — `(0x03, 0x01)` `gen_live_data`

`struct <bfi3fb4Bi3b2hbiffb3ibHBBb` packed at **offset 2** (`calcsize` = 69). 30 values,
in this exact order:

| #  | code | Field (source)                          | Units / meaning                                                         |
| -- | ---- | --------------------------------------- | ----------------------------------------------------------------------- |
| 1  | `b`  | `gnss_data.sat_count`                   | satellites used                                                         |
| 2  | `f`  | `gnss_data.p_acc` (−1 = None)           | position accuracy (m)                                                   |
| 3  | `i`  | `gnss_data.altitude` (−500 = None)      | altitude (m)                                                            |
| 4  | `f`  | `location[0]`                           | **latitude, degrees**                                                   |
| 5  | `f`  | `location[1]`                           | **longitude, degrees**                                                  |
| 6  | `f`  | `modes.batt_volts` (0 = None)           | battery voltage (V)                                                     |
| 7  | `b`  | `enow_v2.channel`                       | ESP-NOW Wi-Fi channel                                                   |
| 8  | `B`  | `power_bits`                            | power\_mode in bits\[0:3]; 5.x sets bits 3–7                            |
| 9  | `B`  | `enow_v2.max_hop_cnt` (0..255)          | mesh max hop count                                                      |
| 10 | `B`  | `modes.mesh_rx` (0..255)                | mesh RX counter                                                         |
| 11 | `B`  | `modes.mesh_relayed` (0..255)           | mesh relayed counter                                                    |
| 12 | `i`  | `rtc.unix(precision=1)` else 0          | unix timestamp (s)                                                      |
| 13 | `b`  | `config.color_id`                       | device colour id                                                        |
| 14 | `b`  | `fusion.orientation`                    | orientation code                                                        |
| 15 | `b`  | `gnss_data.solution_id`                 | GNSS fix / solution id                                                  |
| 16 | `h`  | `get_heading_mot()`                     | **heading (deg, motion)**                                               |
| 17 | `h`  | `fusion.avg_azimuth` (0 if falsy)       | **compass azimuth (deg)**                                               |
| 18 | `b`  | `min(gnss_data.speed, 127)` (−1 = None) | speed (capped 127)                                                      |
| 19 | `i`  | `gnss_data.odometer`                    | odometer                                                                |
| 20 | `f`  | 0                                       | reserved (frame offset 44)                                              |
| 21 | `f`  | 0                                       | reserved (offset 48)                                                    |
| 22 | `b`  | −1                                      | reserved (offset 52)                                                    |
| 23 | `i`  | `modes.uptime_sec`                      | uptime (s)                                                              |
| 24 | `i`  | `config.age`                            | device age                                                              |
| 25 | `i`  | 0                                       | reserved (offset 61)                                                    |
| 26 | `b`  | `modes.power_level`                     | battery health: 0, or 2 below \~3.45 V (4.1.3 packs `modes.power_mode`) |
| 27 | `H`  | 0                                       | reserved (offset 66)                                                    |
| 28 | `B`  | `flags` (`pack_flags`, see below)       | status bitfield                                                         |
| 29 | `B`  | 0                                       | reserved (offset 69)                                                    |
| 30 | `b`  | `modes.batt_pct`                        | **battery %**                                                           |

Flags byte #28 = `pack_flags(is_sos, is_eco_mode, led_brt≥GLOBAL_BRT, gnss_location_set, power_level==2, is_charging, 0, is_mag_cal_needed)` in that bit order. Bit 2 is set at normal brightness
(`GLOBAL_BRT` = 0.6; eco dims to 0.1). Bit 4 means low battery (`change_power_level` logs
`Changing battery health to: {}`). Byte #8 `power_bits` encodes `config.power_mode` in bits\[0:3].

**The reserved slots are constants in every published firmware.** 3.2.12, 4.1.3, 5.0.2 and
5.0.3 all pass the literals above to `struct.pack_into`. The official app (2.3.0,
`useLiveDataParser`) reads each one at the offset shown and discards it. It also runs offset 69
through `unpackFlags` and uses no bit. Neither side gives them a name. `gen_live_data` still
computes `len(config.peers)`, `config.closest_peer`, `config.furthest_peer`, the ms since
`config.last_peer_msg` and `gc.mem_free()` and never packs them, which suggests these slots once
carried such statistics. (The app labels flag bits 2–4 `isDimLeds`, `isGnssLock` and
`isLowBatt`; the firmware sets bit 2 at *full* brightness.)

### Static Data — `(0x01, 0x02)` `gen_static_data`

`buff[2] = total_len & 255`; `buff[3:9] = MAC` (6 bytes); then
`struct <biHBBBbBBBbhhiiibbb` at **offset 9** (`calcsize` = 34); then three
UTF-8 strings concatenated from offset 43 (no per-string prefix; the lengths are fields
17–19). 19 packed values:

| #     | code          | Field                                                   | Meaning                                                         |
| ----- | ------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
| 1     | `b`           | 0                                                       | reserved (frame offset 9)                                       |
| 2     | `i`           | `config.age`                                            | device age                                                      |
| 3     | `H`           | `syst.release_id` (0 if falsy)                          | firmware release id                                             |
| 4     | `B`           | `modes.release_major`                                   | version major                                                   |
| 5     | `B`           | `modes.release_minor`                                   | version minor                                                   |
| 6     | `B`           | `modes.release_patch`                                   | version patch                                                   |
| 7     | `b`           | `config.color_id`                                       | colour id                                                       |
| 8     | `B`           | `pack_flags(is_persistent_north, is_compass_lock, 0×6)` | settings flags; the app reads bit 3 as "enable bond chat"       |
| 9     | `B`           | `pack_flags(1, 0×7)` in 5.x, 0 in 4.1.3                 | capabilities: bit 0 = half-duplex loop (`send_data_v2`) present |
| 10    | `B`           | `modes.service_id`                                      | service id                                                      |
| 11–16 | `b h h i i i` | reserved `(0,0,0,0,0,0)`                                | placeholders (offsets 23–39)                                    |
| 17    | `b`           | `len(device_name)`                                      | string #1 length                                                |
| 18    | `b`           | `len(branch)`                                           | string #2 length                                                |
| 19    | `b`           | `len(wifi_ssid)`                                        | string #3 length                                                |

Then, from offset 43, the three strings back to back: `device_name`, git `branch`
(`'N/A'` if none), `wifi_ssid` (`''`).

Field 9 is a capability byte. 4.1.3, which has no `send_data_v2`, packs 0; 5.x packs 1. The
official app (2.3.0, `useStaticDataParser`) reads bit 0 as `isHalfDuplex` and switches to the TX
handoff when it is set. The app reads fields 1 and 11–16 and discards them; every published
firmware packs them as 0.

### Peer Ping — `(0x06, 0x02)` `gen_peer_ping`

`buff[0:2] = (0x06, 0x02)`; `buff[3:9] = peer MAC` (`hex_to_bin`, 6 bytes); `buff[9]` =
`peer.mesh_hops` clamped to `0..255`. A 40-byte record `struct <ffbbh4BHbb4BiihBbf`
(`calcsize` = 40) is then packed at **offset 10**, followed by the UTF-8 peer name (its byte
length is field #11 below, name bytes from offset 50) and a trailing `struct <bH` =
`(batt_pct, release_id)`. `buff[2]` holds the total length, written last. This is a full
\~53 + name-length-byte peer record, **not** just MAC + two flag bytes — the two peer-flag
bytes are fields 6 and 20 **inside** this struct. (**Confirmed** from the `struct.pack_into`
opcode stream; field labels are the `LOAD_ATTR` names, so semantics are **Confirmed** where a
name is given.)

| #  | code | Field (source)                          | Meaning                                                          |
| -- | ---- | --------------------------------------- | ---------------------------------------------------------------- |
| 1  | `f`  | `peer.lat` (0 if None)                  | latitude                                                         |
| 2  | `f`  | `peer.lon` (0 if None)                  | longitude                                                        |
| 3  | `b`  | `peer.p_acc` (−1 if None)               | position accuracy                                                |
| 4  | `b`  | `min(peer.speed_kph, 127)` (−1 if None) | speed (capped 127)                                               |
| 5  | `h`  | `peer.peer_azimuth` (−1 if None)        | bearing to peer (deg)                                            |
| 6  | `B`  | flag byte A (see below)                 | peer status flags                                                |
| 7  | `B`  | `peer.rgb[0]`                           | colour R                                                         |
| 8  | `B`  | `peer.rgb[1]`                           | colour G                                                         |
| 9  | `B`  | `peer.rgb[2]`                           | colour B                                                         |
| 10 | `H`  | 0                                       | `dtim` in the app (frame offset 26); the firmware always sends 0 |
| 11 | `b`  | peer-name length                        | bytes of the name packed at offset 50                            |
| 12 | `b`  | `peer.rssi` (100 if None)               | last RSSI                                                        |
| 13 | `B`  | `peer.msg_rx`                           | messages received                                                |
| 14 | `B`  | `peer.msg_tx`                           | messages sent                                                    |
| 15 | `B`  | `peer.mesh_rx`                          | mesh RX counter                                                  |
| 16 | `B`  | `peer.mesh_send_count`                  | mesh send counter                                                |
| 17 | `i`  | `peer.last_update` (0 if falsy)         | last-seen tick                                                   |
| 18 | `i`  | `peer.last_coords_unix` (0 if None)     | coords timestamp (s)                                             |
| 19 | `h`  | `peer.distance_diff` (−1 if None)       | distance delta                                                   |
| 20 | `B`  | flag byte B (see below)                 | peer visibility flags                                            |
| 21 | `b`  | `peer.orientation`                      | orientation code                                                 |
| 22 | `f`  | `peer.volts`                            | peer battery voltage                                             |

| Flag byte    | `pack_flags` bit order                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------- |
| A (field 6)  | `sos, is_poi, is_mesh, is_stale, is_collected, 0, is_unknown, 0`; the app reads bit 5 as `isIdle` |
| B (field 20) | `is_hidden, is_locked, 0×6`                                                                       |

Log: `Sending BLE | DataTransfer (0x06, 0x02) for {}`.

### Peer Sync — `(0x06, 0x07)` `gen_peer_sync`

A peer-MAC list: `[0x06, 0x07, total_len & 255, peer_count, mac0(6), mac1(6), …]`
(6-byte MACs, first at offset 4, +6 each).

## ESP-NOW registry: `TOTEM_MSG_MAP`

<Warning>
  Earlier docs decoded the qstr-immediate values with `>>2`, yielding bogus "handler
  names" (`disconn_animation`, `device_power`, `dev_info`, `dev_total_lightsleep_ms`,
  `disabled`, …). Those were **decode artifacts and are wrong**. ESP32 MicroPython
  (REPR\_A) tags qstr-immediates as `(o & 7) == 2` with value `o >> 3`; under the correct
  `>>3` decode **every** `TOTEM_MSG_MAP` value is a struct format string.
</Warning>

`TOTEM_MSG_MAP` in `espnow_conn_v2.py` is a `(cat_id, cmd_id)`-keyed map (2-byte `bytes`
key) whose values are the **ESP-NOW / mesh** payload struct formats. Recovered in full
with the corrected `>>3` decode (**Confirmed**):

| `(cat, cmd)`                | Format               | Size |
| --------------------------- | -------------------- | ---- |
| `(0,0)` / `(0,1)` / `(0,2)` | `<BBffbbhbbb6B`      | 23 B |
| `(1,0)`                     | `<BBB7bHbb`          | 14 B |
| `(1,2)`                     | `<BBiffHii`          | 24 B |
| `(1,5)`                     | `<BB9B8bhh4B`        | 27 B |
| `(1,6)`                     | `<BBbbB`             | 5 B  |
| `(1,7)`                     | `<3Bbb`              | 5 B  |
| `(2,0)`                     | `<BB6BffbbHbbbhhBBi` | 33 B |
| `(3,1)` / `(3,2)`           | `<BBHH4b4BHHBb`      | 20 B |
| `(7,0)`                     | `<BBffbbbHbbH`       | 19 B |
| `(7,1)`                     | `<BBHbffb`           | 14 B |

`EXTENDED` holds the longer variants, `(cat, cmd) → {frame length → fmt}`:

| `(cat, cmd)` | key | Format                              | Size |
| ------------ | --- | ----------------------------------- | ---- |
| `(0,0)`      | 25  | `<BBffbbhbbb6B`                     | 23 B |
| `(0,0)`      | 59  | `<BBffbbhbbb6Bhii4BhHehhffbB`       | 57 B |
| `(0,0)`      | 72  | `<BBffbbhbbb6Bhii4BhHehhffbBBiiBBb` | 69 B |
| `(1,6)`      | 5   | `<BBbbB`                            | 5 B  |
| `(1,6)`      | 29  | `<BBbb9BBB3i`                       | 27 B |
| `(2,0)`      | 45  | `<BB6BffbbHbbbhhBBiffh`             | 43 B |

The keys are **total frame lengths including the SyncWord**, not schema ids: `Parser.aread`
picks `EXTENDED[key][len(frame)]` on an exact match. Failing that, if the frame's **`cmd_id`
is 0** and it is at least 72 bytes long, the 72 entry is used; otherwise `aread` logs
`Non-extended payload being used` and falls back to `TOTEM_MSG_MAP`. `compass_mesh` only
upgrades a `(2,0)` frame to the 45 variant on an exact length match. Commands 1 and 2 of
category 0 have no `EXTENDED` entry, so a receiver decodes only the first 23 bytes of a bond
frame. The struct is unpacked from `frame[2:]`, so its leading `BB` is the echoed
`(cat_id, cmd_id)` at frame offsets 2-3.

<Note>
  The `cmd_id == 0` fallback is keyed on the command byte alone, so in principle it also covers
  `(2,0)`, whose `EXTENDED` dict has no 72 entry. In practice no `(2,0)` frame reaches `aread`:
  `EspConn.recv` handles category 2 command 0 inline and `continue`s. **Confirmed**
  (`espnow_conn_v2.dis:7515-7540` and `:6698-6790`).
</Note>

Every key in the table is `calcsize(fmt) + 2` — except `EXTENDED[(1,6)][5]`, whose format
`<BBbbB` also has `calcsize` 5 and so would need a 7-byte frame. Nothing emits either length;
see [demi-god](/protocols/demigod#frame-layout).

The category 1 (demi-god) map entries describe only the **fixed head** of the frames the
firmware actually sends. Each `espnow_msg.demigod_gen_*` packer extends its buffer past the
mapped fields: `(1,2)` builds 30 bytes for a 26-byte entry, `(1,7)` builds 12 for 7, and
`(1,0)` builds 16 plus four variable-length UTF-8 strings plus an optional trailing byte.
`struct.unpack` ignores the surplus. Field tables are on the
[demi-god page](/protocols/demigod#frame-layout).

The field tables below give **frame offsets** (SyncWord at 0), as the firmware's `pack_into`
calls use them. All are **Confirmed** from the builders in `espnow_conn_v2.Messages` and the
readers in `Parser`, and the peer and locate layouts are exercised on hardware by the
[ESP32 emulator](/reference/esp32-emulator), whose Go codec (`mesh` package) matches frames
packed by CPython's `struct` with these formats byte for byte.

### Peer frame `(0, cmd)`

`ENOW_PEER_BUFF = bytearray(108)` is a module-level buffer in `project_data`; `Messages`
holds a `memoryview` of it and always sends all **108 bytes**. Command 0 is the status, 1 a
bond request or confirmation, 2 an unbond notice.

Four methods write it, at different times — a caller that only calls `gen_peer_msg` sends
whatever the others last left behind:

| Writer               | Offsets                                                                    | When                                         |
| -------------------- | -------------------------------------------------------------------------- | -------------------------------------------- |
| `build_all`          | 0-1 (SyncWord)                                                             | once at start-up, then calls the three below |
| `update_location`    | 4, 8, 12, 13                                                               | on each GNSS fix                             |
| `update_sos`         | 16                                                                         | when SOS is toggled                          |
| `update_device_data` | 59-70 and the name at 71                                                   | when the device name changes                 |
| `gen_peer_msg`       | 2, 3, 14, 17, 18, 19, 25, 27, 31, 35-58 and the 5-byte tail after the name | per frame                                    |

| Offset | Code        | Field                           | Source                                                                                                                                        |
| ------ | ----------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 4      | `f`         | latitude                        | `gnss_data.location[0]`, 0 without a fix                                                                                                      |
| 8      | `f`         | longitude                       | `gnss_data.location[1]`, 0 without a fix                                                                                                      |
| 12     | `b`         | position accuracy (m)           | `min(max(int(p_acc), -1), 127)`, −1 without a fix                                                                                             |
| 13     | `b`         | speed (km/h)                    | `gnss_data.speed`, not clamped                                                                                                                |
| 14     | `h`         | compass azimuth (deg)           | `int(fusion.avg_azimuth)`                                                                                                                     |
| 16     | `b`         | SOS                             | `config.is_sos`                                                                                                                               |
| 17     | `b`         | orientation                     | `fusion.orientation`: 1 upright, 2 lying flat                                                                                                 |
| 18     | `b`         | ack                             | bond confirmation (command 1)                                                                                                                 |
| 19     | `6B`        | target MAC                      | always zero: no caller passes `target_mac`                                                                                                    |
| 25     | `h`         | unscheduled                     | always 0 from a Totem, but **read**: a receiver that sees non-zero skips predicting the sender's next window (`espnow_conn_v2.dis:9755-9756`) |
| 27     | `i`         | time of day (ms since midnight) | `rtc.itod` if the clock came from GNSS, else −1                                                                                               |
| 31     | `i`         | Unix time (s)                   | `rtc.unix()` likewise, else −1                                                                                                                |
| 35     | `B`         | flags                           | bit 0: the phone is connected over BLE                                                                                                        |
| 36     | `3B`        | firmware major, minor, patch    | `modes.release_*`                                                                                                                             |
| 39     | `h`         | altitude (m)                    | `gnss_data.altitude`, −500 unknown                                                                                                            |
| 41     | `H`         | uptime (min)                    | `modes.uptime_sec // 60`                                                                                                                      |
| 43     | `e`         | battery voltage                 | half float, `modes.batt_volts` or 0 — see the note below                                                                                      |
| 45     | `h`         | heading of motion (deg)         | `get_heading_mot()`, −1 until 10 m travelled                                                                                                  |
| 47     | `h`         | odometer (m)                    | `round(gnss_data.odometer / 1000)` of an odometer in mm                                                                                       |
| 49-69  | `ffbBBiiBB` | reserved                        | constants 0.0, 0.0, −1, then zeros; never read                                                                                                |
| 70     | `b`         | name length *n*                 | UTF-8 bytes of the device name                                                                                                                |
| 71     | *n* bytes   | name                            | `get_device_name()`                                                                                                                           |
| 71+*n* | `b`         | GNSS solution                   | 0 none or poor, 1 within 3.5 m, 2 within 15 m                                                                                                 |
| 72+*n* | `b`         | position source                 | 1 the Totem's receiver, 2 the phone                                                                                                           |
| 73+*n* | `H`         | release id                      | `syst.release_id`                                                                                                                             |
| 75+*n* | `b`         | battery %                       | `modes.batt_pct`                                                                                                                              |

**The name.** `update_device_data` writes it with
`pack_utf8_str(buff=peer_msg, start=71, text=get_device_name())`, which is a bare
`buff[71:71+n] = data` — its `max_size` argument is accepted and never used. The buffer is a
`memoryview` of 108 bytes, so a name of 33-37 bytes fits the slice but overwrites the field
region after it, and `gen_peer_msg`'s `struct.pack_into('<bbHb', …, 71+n)` then raises; a
name of 38 bytes or more makes the slice assignment itself raise. Either way nothing goes
out. 32 bytes (`108 − 71 − 5`) is the largest name that works. The buffer is reused, so bytes
after the tail can hold leftovers of a longer earlier name. **Confirmed**
(`f_lib_bitwise.dis`, `pack_utf8_str`; `espnow_conn_v2.dis`, `update_device_data`).

**The battery voltage** is a `struct` `'e'` half float. MicroPython's software
`mp_encode_half_float` (`py/binary.c`, used on ESP32 because the port has no native
`_Float16`) rounds half up, lets a mantissa carry spill into the exponent field, flushes
values that should become the largest subnormals to zero, and — the part that matters —
**has no range check**. Past the half's \~65504 limit the exponent runs off the end of its
five bits and into the sign bit, so a voltage of 131072 encodes as −0 and 100000 as a NaN.
The Go codec in `mesh/half.go` is a port of both directions and matches a Totem byte for
byte; its encoder refuses a finite magnitude above 65504 rather than silently changing it.
The encoder behaviour is **Inferred** — it is MicroPython runtime code, not in the frozen
bytecode — but the field being `'e'` at offset 43 is **Confirmed** from
`'<4BhHehhffbB'` at `espnow_conn_v2.dis:1647`.

### Locate `(2,0)`

`gen_mesh_msg` fills `ENOW_MESH_BUFF = bytearray(45)`: the 45-byte `EXTENDED` variant.

| Offset | Code | Field                        | Source                                                  |
| ------ | ---- | ---------------------------- | ------------------------------------------------------- |
| 4      | `6B` | origin MAC                   | the originating Totem                                   |
| 10     | `f`  | origin latitude              |                                                         |
| 14     | `f`  | origin longitude             |                                                         |
| 18     | `b`  | origin position accuracy (m) |                                                         |
| 19     | `b`  | SOS                          |                                                         |
| 20     | `H`  | UID                          | `randint(1, 65534)`; relays keep it                     |
| 22     | `b`  | reply requested (`is_ack`)   | 1 locate request, 0 reply                               |
| 23     | `b`  | minimum RSSI                 | −127: a receiver outside `[min, max]` ignores the frame |
| 24     | `b`  | maximum RSSI                 | 0                                                       |
| 25     | `h`  | minimum distance             | −1, never read                                          |
| 27     | `h`  | maximum distance             | −1, never read                                          |
| 29     | `B`  | hop count                    | 0 at the origin, +1 per relay                           |
| 30     | `B`  | maximum hops                 | 99                                                      |
| 31     | `i`  | expiry (Unix s)              | origin time + 120                                       |
| 35     | `f`  | last-hop latitude            | each relay writes its own position                      |
| 39     | `f`  | last-hop longitude           |                                                         |
| 43     | `h`  | relay minimum distance (m)   | `MESH_RELAY_MIN_DIST` = 10                              |

### Smart Group beacon `(7,0)`

`gen_auto_bond_grp` builds `21 + 16 × n` bytes: `<ffbbbHbbH` at offset 4, then one 16-byte
record per member.

| Offset     | Code           | Field                                                         |
| ---------- | -------------- | ------------------------------------------------------------- |
| 4          | `f`            | host latitude                                                 |
| 8          | `f`            | host longitude                                                |
| 12         | `b`            | host position accuracy (m)                                    |
| 13         | `b`            | colour id: 0 while advertising, the host's colour on finalize |
| 14         | `b`            | instruction: 1 advertise, 3 finalize, −1 abandon              |
| 15         | `H`            | group UID (`smart_grp_uid`)                                   |
| 17         | `b`            | member count *n*                                              |
| 18         | `b`            | reserved: always 8, never read                                |
| 19         | `H`            | time left in the host's window (ms), 0 on finalize or abandon |
| 21 + 16*i* | `6B` + `<ffbb` | member MAC, then latitude, longitude, accuracy, colour id     |

The member MAC is written as a raw 6-byte slice assignment from the **key** of the host's
`modes.auto_bond_grp` dict, and the four packed values are that entry's items 0-3 — so a
member's position is whatever the host last heard, not a live value. Offsets 12/13 (the
host's own accuracy and colour) come from the host's GNSS and the `clr_id` argument; the
host sends `clr_id = 0` while advertising and `config.color_id` on finalize. **Confirmed**
(`espnow_conn_v2.dis:1296-1447`, `gen_auto_bond_grp`).

### Smart Group reply `(7,1)`

16 bytes built in `Parser._smart_group`: `<Hbffb` at offset 4 = group UID, join flag (+1 join,
−1 leave), latitude, longitude, position accuracy.

### ESP-NOW frame envelope

On the radio these payloads sit inside an ESP-NOW application frame with a fixed
**SyncWord** prefix and **no CRC** (**Confirmed**):

```text theme={null}
0       2       3       4                    N
│ A7 74 │ cat_id│ cmd_id│ payload (struct)   │
│ Sync  │  u8   │  u8   │                    │
```

Frame validation = **SyncWord `0xA7 0x74` match + `len ≥ 4` + per-`(cat, cmd)`
payload-size check**. There is **no checksum or CRC** on the ESP-NOW frame. (The rodata
string `Invalid Checksum value for: {}` belongs to the u-blox UBX GNSS parser, not this
path.) A mesh dedup UID (`uint16`) sits at mesh-frame offset 20.

## Message identity & lifetime

Only the locate frame `(2,0)` floods the mesh, so only it carries an identity. Peer frames
have no UID or sequence number; a receiver keys them on the ESP-NOW source MAC and the receive
time.

| Symbol                                         | Role                                                                                                                                                                                      |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| locate UID (offset 20)                         | `gen_mesh_msg`: `randint(1, 65534)` per new frame; relays and the one reply repeat keep it                                                                                                |
| `Parser.recent` / `add_recent`                 | UIDs seen. With no `timeout_unix` the lifetime is `MSG_EXP_MSECS`; with one it is `(timeout_unix − now) × 1000 + 15000`, clamped to 15-400 s. At 512 entries the expired ones are dropped |
| `MSG_EXP_MSECS`                                | default lifetime of a `recent` entry (150000 ms)                                                                                                                                          |
| `MESH_PEER_MSG_LIMIT`                          | locate requests per stale peer (999)                                                                                                                                                      |
| `msg_offset_ms`                                | per-peer phase estimate: an EWMA (`0.8 × old + 0.2 × error`) of how far each frame landed from the predicted `peer.next_msg`, clamped to ±500 ms when the next window is predicted        |
| `last_msg_rx`, `last_msg_tx`, `last_msg_ticks` | liveness tracking                                                                                                                                                                         |

`gen_msg_uid`, `MSG_IN_BUFF` and `MSG_OUT_BUFF` belong to the chat module (`chat_msg`), which
no module imports in 5.0.3.

This lets a message flood the mesh once and be dropped as a duplicate on re-receipt
(`Demi-god command ignored, already received` is the same idea in the command path).

## Chunking

Files larger than one GATT write are moved by a common chunking layer (`f_ble/chunking.py`,
used by BLE OTA and, since v5.0.3, by the log uploader). Two headers are involved:

| Header                | Format                                                     | Size                               | Carried on                     | Role                             |
| --------------------- | ---------------------------------------------------------- | ---------------------------------- | ------------------------------ | -------------------------------- |
| Transfer header       | `<HBBBiHiB` at offset 2 of `[0x02, 0x02, …]`               | 16 B (+ SHA-256, name, `err_no`)   | `…-0001` (uploader, indicated) | announce / finish / abort a file |
| Chunk header (v5.0.3) | `CHUNK_HDR_FMT = '<HHiH'` at offset 2 of `[0x00, 0x02, …]` | `CHUNK_HDR_SZ = 12` B incl. prefix | `…-0003` (`chars__on_demand`)  | one data chunk                   |

<Note>
  In **v5.0.2** `f_ble/file_upload.py` imported `CHUNK_HDR_FMT` and `CHUNK_HDR_SZ` from
  `chunking`, but `chunking` never defined them, and nothing imported the uploader. Earlier
  versions of this page inferred `CHUNK_HDR_FMT = '<HBBBiHiB'` from that. **v5.0.3** defines
  both names explicitly, and they describe the 12-byte **chunk** header. `<HBBBiHiB` is the
  transfer header packed inline by `gen_transfer_buff`.
</Note>

### Transfer header (`gen_transfer_buff`)

`buff[0] = 2`, `buff[1] = 2`, then `struct.pack_into('<HBBBiHiB', buff, 2, …)`
(**Confirmed**, both versions):

| # | code | Field          | Type      | Meaning                                                                         |
| - | ---- | -------------- | --------- | ------------------------------------------------------------------------------- |
| 1 | `H`  | `file_id`      | uint16 LE | `= sha256[0] \| sha256[1]<<8`                                                   |
| 2 | `B`  | `status_id`    | uint8     | transfer status (see below)                                                     |
| 3 | `B`  | `action_id`    | uint8     | transfer action (see below)                                                     |
| 4 | `B`  | `file_type_id` | uint8     | content type (see below)                                                        |
| 5 | `i`  | `byte_pos`     | int32 LE  | byte offset                                                                     |
| 6 | `H`  | `chunk_no`     | uint16 LE | chunk index                                                                     |
| 7 | `i`  | `file_size`    | int32 LE  | total file size (bytes)                                                         |
| 8 | `B`  | flags          | uint8     | v5.0.2: literal `0`. **v5.0.3**: `pack_flags(is_last_chunk, is_origin_compass)` |

After the 16-byte header: `buff[18:50]` = 32-byte SHA-256 (when set); `buff[50]` =
`name_len` (v5.0.3 writes `0` when there is no name), then the UTF-8 file name, then a
trailing `err_no` byte.

### Chunk header (v5.0.3, `FileUploader._stream`)

`[0x00, 0x02]` + `struct.pack_into('<HHiH', buff, 2, file_id, n, byte_pos, chunk_no)` + `n`
data bytes, written to `…-0003` with `send_update=True`. `n` is the bytes read for this chunk
and `byte_pos` its offset (**Confirmed** from the operands); `chunk_no` counts from 1
(**Inferred** from the counter variable). The payload per chunk is
`min(ble.mtu_payload(), 247) - CHUNK_HDR_SZ` (20 − 12 before the MTU is known). v5.0.3
tracks the negotiated MTU in `BleLite.mtu` for this purpose.

### Header enums (Confirmed)

| Field          | Value | Meaning               |
| -------------- | ----- | --------------------- |
| `status_id`    | `1`   | normal / in-progress  |
| `status_id`    | `4`   | error / abort         |
| `action_id`    | `0`   | header / announce     |
| `action_id`    | `1`   | last-chunk / complete |
| `file_type_id` | `2`   | upload-to-app         |

`file_id` is derived from the file's SHA-256: `file_id = sha256[0] | sha256[1] << 8`
(u16). The transfer *destination* enum is separate: `SAVE_TO_VFS = 1`, `SAVE_TO_OTA = 2`, and
v5.0.3 adds `UPLOAD_TO_APP = 3`.

## File transfer

`f_ble/file_upload.py` (`FileUploader`) pushes device log files (`events-*` files) to the
app so the app can forward them to the cloud. It is **new in practice in v5.0.3**: v5.0.2
shipped the module but never imported it. In v5.0.3 `ble_manager` starts it as the
`ble_file_upload` task, and it only runs when every gate is open:

| Gate (`_gate` / `_pause_reason`) | Condition                                                                                                    |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| build switch                     | `modes.is_ble_log_upload` (`[Upload] Disabled in this build (modes.is_ble_log_upload = 0)`)                  |
| app capability                   | Ready frame `caps` bit 0 (`app lacks upload support`; otherwise `suspended until BLE is next turned on`)     |
| phone online                     | `modes.ble_ticks_app_internet` recent, set by `(12,3)` flags bit 0 or a `(6,9)` cloud update (`no internet`) |
| link idle                        | not while the app owns TX, critical records are in flight, an ESP-NOW window is open, or `…-0003` is busy    |
| device state                     | Vibe mode, no peer task, no scheduled disconnect, enough free memory (`MIN_MEM_FREE`)                        |

Uploads mark themselves as the half-duplex **non-critical owner** (`noncrit_owner = 'upload'`),
which may delay the next TX handoff by up to 2 s. After `MAX_FILE_RETRIES` failures a file is
suspended until BLE is next turned on. v5.0.3 also toggles nav-log fast rotation depending on
whether a backlog exists.

### App→device reply header (`FileUploader.on_header`)

The app replies with an 18-byte header, `struct <BBHbBBiHiB` (**Confirmed**, guarded by
`len(data) >= 18`), starting `(0x02, 0x03)`. In v5.0.3 it is written to **`…-0001`**:
`recv_status_msgs` routes `(2,3)` to `uploader.on_header`. Used fields: `file_id = t[2]`,
`status = t[3]`, `action = t[4]`, `chunk = t[7]`.
Log: `[Upload] App header | status: {} | action: {} | chunk: {} | err: {}`.

| App field | Value         | Meaning                                         |
| --------- | ------------- | ----------------------------------------------- |
| `status`  | `∈ {2, 3, 4}` | terminal result (`evt_app_result` set)          |
| `action`  | `4`           | RESUME at `chunk` (`resume_chunk = chunk or 1`) |
| `action`  | `1`           | READY / proceed                                 |

### Control flow

| Event                                                                     | Meaning                                        |
| ------------------------------------------------------------------------- | ---------------------------------------------- |
| `[Upload] Starting {} \| mem_free: {}`                                    | a file is being offered                        |
| `[Upload] App header \| status: {} \| action: {} \| chunk: {} \| err: {}` | app reply                                      |
| `[Upload] Resuming at chunk {}`                                           | resume an interrupted transfer                 |
| `[Upload] App did not answer the header`                                  | no reply within `HEADER_TIMEOUT_MS`            |
| `[Upload] Streamed {} B in {} chunks \| {} ms \| mem_free: {}`            | all chunks sent                                |
| `[Upload] No server confirmation from App`                                | no terminal status within `CONFIRM_TIMEOUT_MS` |
| `[Upload] Delivered and deleted {}`                                       | success: the file is removed from the device   |

### Result codes (Confirmed)

`RES_DONE = 1`, `RES_NO_APP = 2`, `RES_RETRY = 3`, `RES_FAILED = 4`, `RES_CANCEL = 5`,
`RES_ABORT = 6` (logged as `result: {} (1=done 2=no app 3=retry 4=failed 5=cancel 6=abort)`).
