> ## 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` (schema-versioned variants, `(cat, cmd) → {schema_id → fmt}`):

| `(cat, cmd)` | schema | 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 |

Every `<BB…` format leads with the echoed `(cat_id, cmd_id)`. The `(0,*)` 23-B family
(`<BBffbbhbbb6B`) is the **mesh peer beacon**: two floats (lat/lon) + flags + 6-byte MAC —
the ESP-NOW analogue of the BLE Peer records. A BLE-only client ignores this table.
Field-by-field mesh semantics are **Partial** (not fully labelled).

### 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

Mesh/peer messages are de-duplicated and expired:

| Symbol                                         | Role                                                          |
| ---------------------------------------------- | ------------------------------------------------------------- |
| `gen_msg_uid`                                  | generate a unique id per message (`uint16 randint(1, 65534)`) |
| `MSG_EXP_MSECS`                                | message expiry window (150000 ms)                             |
| `MSG_IN_BUFF`, `MSG_OUT_BUFF`                  | inbound / outbound message buffers                            |
| `msg_interval_ms`, `msg_offset_ms`             | send scheduling                                               |
| `MESH_PEER_MSG_LIMIT`                          | per-peer message cap                                          |
| `last_msg_rx`, `last_msg_tx`, `last_msg_ticks` | liveness tracking                                             |

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)`).
