firmware_v5.0.3.bin image (and unchanged from v5.0.2 unless marked). The BLE parts are
also tested on hardware. Confidence is graded Confirmed / Inferred / Partial
throughout.
No cryptographic secrets are required
The single most important fact for a client author: you need no keys, no pairing, no signatures, and no shared secret to interoperate. Nothing on the application path is authenticated or encrypted (Confirmed).The ESP-NOW ROM module contains the concept of encryption (
set_pmk, lmk,
encrypt), but no frozen Python module references those symbols, and add_peer(mac) is
always called with a single positional argument, so lmk=None and encrypt=False. The
only crypto in the image is the bundled (and, on the app path, unused) mbedTLS stack plus
the OTA SHA-256. ble_keys.bin holds optional BLE reconnect secrets — not required to
connect.BLE client
The Totem is a BLE peripheral; your client is the central. No pairing is required. A working reference implementation in Go,totemctl, lives in this repository
(protocol/, client/, cmd/totemctl/). It has been verified against a real device on
firmware 4.1.3 and 5.0.3.
Connect
Bluetooth is off until the user double-presses the power button, which toggles advertising; the crystal breathes blue while it advertises. The device advertises the nametotem and the service UUID, so either can be matched.
ble_core.py registers these characteristics under the service, all with GATT flags
0x3E (READ | WRITE | WRITE_NO_RESP | NOTIFY | INDICATE):
Direction is fixed: client → device is a GATT write; device → client is
notify/indicate (on connection handle 0). Subscribe to both
…-0001 and …-0002. The
data characteristic’s GATT value buffer is 185 bytes, so a single write must fit in it.
MTU is client-negotiated; macOS negotiates 256 with the device.
Handshake (ConnStatus-Ready)
1
Subscribe
Enable notifications on
…-0002 and …-0001.2
Report app state (optional)
Write
[0x03, 0x00, bits] to …-0001: bit0 isActive, bit1 isFocused, bit2 isLocked,
bit3 isUiClosed, bit4 isService, bit5 lets the device drop BLE on its own schedule.3
Write the Ready frame
Write
[0x00, 0x01, 0x01, 0x00] to …-0001 within 15 s of connecting (v5.0.3 drops
silent links). A last byte of 0x00 selects the legacy full-duplex transmit loop,
which works on every platform. A nonzero frame_schema_id selects the half-duplex loop,
which stalls on macOS/iOS. v5.0.3 accepts an optional 5th
byte whose bit 0 announces file-upload support; leave it out unless you implement uploads.4
Request and acknowledge records
Write
(0x01, 0x01) to …-0002 to request Static Data. In the legacy loop the device
repeats Static Data, the WiFi list and Peer Sync until you acknowledge them with
(0x01, 0x00), (0x02, 0x00) and a cat-6 command such as (0x06, 0x08). Live Data then
arrives every ~3 s, and Peer Pings whenever a peer changes. Read them with the gen_*
layouts below.5
Send commands, then disconnect
Write commands to
…-0002 at any time; the full list is in the
command map. To disconnect gracefully, write
[0x00, 0x03] to …-0001.recv_status_msgs applies these gates (Confirmed): conn_mode == 1 sets TX owner and
clears pending static/peer requests; frame_schema_id > 0 sets TX ready and makes the
legacy loop exit in favour of send_data_v2. The ids 25, 45, 59, 72 are real EXTENDED
schema variants, but no BLE record layout depends on the schema id. In the half-duplex
loop the device waits for the ATT confirmation of every indication (500 ms timeout), hands TX
to the app with [0x04, 0x02, 0x02, …] on …-0001, and expects [0x04, 0x03, 0x02] to
take it back. See Half-duplex & comms handoff.Messages to read (BLE gen_* layouts)
All BLE records prefix buff[0:2] = (cat_id, cmd_id). Confirmed layouts:
Live Data (0x03, 0x01) — gen_live_data, struct <bfi3fb4Bi3b2hbiffb3ibHBBb at
offset 2 (69 B). Fields in order:
The
flags byte (field 28) is pack_flags(is_sos, is_eco, led_brt ≥ GLOBAL_BRT, gnss_location_set, power_level == 2, is_charging, 0, is_mag_cal_needed): bit 2 is normal
(undimmed) brightness and bit 4 is low battery.
The reserved fields hold these constants in every published firmware (3.2.12 to 5.0.3), and
the official app reads and discards them, so a client can ignore them.
Static Data (0x01, 0x02) — gen_static_data. buff[2] = total_len & 255,
buff[3:9] = MAC (6 B), then struct <biHBBBbBBBbhhiiibbb at offset 9 (34 B), then three
UTF-8 strings concatenated from offset 43. There are no per-string prefixes: their lengths
are the struct’s last three fields.
Peer Ping
(0x06, 0x02) — gen_peer_ping. buff[2] = total length, buff[3:9] =
peer MAC (6 B), buff[9] = mesh hops, then struct <ffbbh4BHbb4BiihBbf at offset 10 (40 B):
lat, lon, p_acc, speed, bearing, flag byte A, r, g, b, 0 (the app’s dtim), name length, rssi, msg_rx, msg_tx,
mesh_rx, mesh_send_count, last_update, last_coords_unix, distance_diff, flag byte B,
orientation, volts. Then the peer name (from offset 50) and <bH = (batt_pct, release_id).
A = pack_flags(sos, is_poi, is_mesh, is_stale, is_collected, 0, is_unknown, 0),
B = pack_flags(is_hidden, is_locked, 0×6). The app reads A’s bit 5 as isIdle; the
firmware always sends 0 there. is_unknown is set once a peer has had no
coordinates for 2 h (v5.0.3; 4 h in v5.0.2).
Peer Sync (0x06, 0x07) — gen_peer_sync: a peer-MAC list,
[0x06, 0x07, total_len & 255, peer_count, mac0(6), mac1(6), …].
ESP-NOW mesh client
To join the mesh peer-to-peer instead of going through a phone, send/receive raw ESP-NOW frames on the fleet’s channel.Radio setup
The fleet is pinned to a single channel; ESP-IDF drops off-channel frames, so a mesh client
must match it. Channel 6 is the built-in default (Confirmed), but the live value can
be changed at runtime (Partial) — see what to confirm on-device.
Frame layout
- SyncWord = the 2 bytes
0xA7 0x74(literal in the image). - Validation is SyncWord match +
len >= 4+ a per-(cat, cmd)payload-size check. - There is no CRC or checksum on the ESP-NOW frame. (The rodata string
Invalid Checksum value for: {}belongs to the u-blox UBX GNSS parser, not this path.) - Mesh dedup UID is a
uint16 randint(1, 65534)at mesh-frame offset 20;MSG_EXP_MSECS = 150000ms.
Payload formats (TOTEM_MSG_MAP, corrected)
Every payload leads with the echoed (cat_id, cmd_id) as BB.
EXTENDED schema-versioned variants — (cat, cmd) → {schema_id → fmt}:
The
(0,*) 23-byte family is the mesh peer beacon: two floats (lat/lon), flags, and a
6-byte MAC. Field-by-field mesh semantics beyond the struct layout are Partial — the
byte layouts are recovered, but individual fields are not fully labelled.
Custom OTA server
You can point a Totem at your own OTA server; it needs no signing key (SHA-256 only, plain HTTP). The contract (Confirmed):1
Device announces
POST http://api.totemportal.com/devices/{MAC}/ota (plain HTTP, no auth) with JSON body
{version, endpoint_id, device_type_id, lat, lon, gnss_time, release_id}.2
Server replies with a release object
Expose
ota_url, version, product, branch, release_code, release_id.3
Device fetches the manifest
GET {ota_url}/contents.json — a JSON array of filenames. The device picks the
entry ending .bin (firmware) or .tgz (preview).4
Download, verify, flash
The device downloads the chosen file, verifies SHA-256 only, flashes, reboots, and
reports back with
POST …/ota?updated.A device WebSocket to
api.totemportal.com (ws://) carries push OTA triggers shaped
like {"cmd":…} (Partial). A demi-god ESP-NOW OTA trigger also exists
(demigod_gen_ota_update), but its exact (cat, cmd) and struct are not recovered —
do not fabricate them.Chunked transfer
Only needed if your client accepts the device’s log uploads (v5.0.3) or does BLE OTA. A client that sends the plain 4-byte Ready frame never receives uploads. Layouts (Confirmed; details in chunking):- Transfer header (announce / finish), indicated on
…-0001:[0x02, 0x02]+struct '<HBBBiHiB'= file_id, status_id, action_id, file_type_id, byte_pos, chunk_no, file_size, flags (v5.0.3: bit0 last chunk, bit1 from compass). Then the 32-byte SHA-256 atbuff[18:50], the name length atbuff[50], the name, anderr_no.file_id = sha256[0] | sha256[1] << 8. - Chunk (v5.0.3), notified on
…-0003:[0x00, 0x02]+struct '<HHiH'= file_id, length, byte_pos, chunk_no, then the data (CHUNK_HDR_SZ = 12). - App reply, written to
…-0001(v5.0.3):(0x02, 0x03)+struct '<HbBBiHiB', 18 bytes in total. It is read as file_id, status, action, chunk:status ∈ {2, 3, 4}is terminal,action 4= resume atchunk,action 1= ready.
Overall result codes:
1 done, 2 no-app, 3 retry, 4 failed, 5 cancel, 6 abort.