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.On the BLE side this is checkable directly: BleLite.enable calls ble.active(True) and
nothing else, and the only ble.config() call anywhere in the 94 frozen modules is
ota_ble’s mtu=512 — no bond, mitm, le_secure or io option is ever set, and the
IRQ handler has no encryption or passkey branch (Confirmed).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; the crystal breathes blue while it advertises. The primary advertisement carries the flags, the 128-bit service UUID and the local nametotem; the appearance spills into the scan response, so a
passive scan sees the name and UUID but not the appearance (Confirmed — see
advertising). Advertising interval is 50 ms.
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 — the firmware passes a literal
0 to both
gatts_notify and gatts_indicate, so it only ever serves one central). Subscribe to
both …-0001 and …-0002; a single write must fit the target characteristic’s value
buffer above, whatever the MTU (Confirmed, ble_manager.dis:4308-4400).
MTU is client-negotiated: the device never calls gattc_exchange_mtu and sets no preferred
MTU on the app path, so it starts at 23 and adopts whatever _IRQ_MTU_EXCHANGED reports
(Confirmed). macOS negotiates 256 (observed on hardware, not derivable from the image).
Right after connecting the device asks for a 10–20 ms connection interval, slave latency 0
and a 12 s supervision timeout via gap_conn_param_update(0, 10000, 20000, 0, 12000).
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
schedules a disconnect for 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) — any command
in the category clears its pending flag, because recv_data_msgs just assigns
controller.<cat>_cmd_id = cmd_id. Peer Sync is pending from the start
(Controller.__init__ leaves peer_cmd_id = 1), but the device withholds it until
Static Data has been acknowledged (Static data not yet sent, skipping BLE Peer Sync).
Live Data then arrives every ~3 s, and Peer Pings whenever a peer changes — one per loop
pass, and only while the last Live Data was under 10 s ago. 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 — that sets is_graceful_disconn, so the device does not
auto-reconnect afterwards. Drop the link without it and the device silently re-advertises
for 45 s.recv_status_msgs applies these gates (Confirmed, ble_manager.dis:2753-3119):
conn_mode == 1 clears pending static/peer requests; frame_schema_id > 0 sets TX ready
and makes the legacy loop exit in favour of send_data_v2; TX owner is set only when
both hold — the evt_is_tx_owner.set() lives inside the frame_schema_id > 0 branch.
A short frame is not an error: conn_mode and frame_schema_id default to 0 when the
write is under 3 or 4 bytes, and is_upload_supported to 0 under 5. 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 a 12-byte
[0x04, 0x02, 0x02] + 00×9 indication on …-0001, and expects [0x04, 0x03, 0x02]
(bit 1) to take it back; [0x04, 0x03, 0x01] (bit 0) revokes it instead. See
Half-duplex & comms handoff.Keeping the link alive
The device watchescontroller.status__conn. If it is anything but 1 for 15 s it
counts a stall, logs App never sent ConnStatus within {} ms - link treated as hung, and
raises evt_ble_schedule_disconn; the scheduled-disconnect manager then drops the link and
parks BLE for 30 s (90 s, and with no fast-reconnect bursts, after two hung links in a row).
Sending the Ready frame promptly is the whole fix. (Confirmed,
ble_manager.dis:3840-3890 and compass.dis:5747-6039.)
Before it goes away the device announces its intent on …-0001, three times, 50 ms
apart, as a 10-byte frame (Confirmed, ble_manager.dis:4934-5084):
A client that handles
[0x00, 0x05] can schedule its own rescan instead of hunting blind.
During the off window the device also advertises a connectable 1.2 s burst every ~8 s,
so a client that keeps scanning will usually reconnect before the window ends.
Setting bit 5 of the app-runtime byte ([0x03, 0x00, bits]) is what permits the routine
version of this cycle: espnow_conn_v2 only requests a scheduled disconnect when
modes.is_ble_auto_reconn is set and the app has reported itself inactive (bit 0 clear)
for more than 10 s ([ESP-NOW] | App allows BLE Auto Reconnect → [ESP-NOW] | Requesting BLE Scheduled Disconnect). Leave bit 5 clear and the device stays connected — the 15 s
hung-link guard is the only other thing that raises the event (Confirmed,
espnow_conn_v2.dis:3030-3160).
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. The repository’s Gomesh package encodes and decodes these
frames, and the emulator package plus cmd/totememu run a complete Totem on an ESP32 with
TinyGo: see ESP32 emulator.
Radio setup
The fleet is pinned to channel 6 (Confirmed, and on hardware); ESP-IDF drops off-channel
frames, so a mesh client must match it.
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 longer variants — (cat, cmd) → {frame length → fmt}. The keys are total frame
lengths including the SyncWord, not schema ids (for (0,0), 72 means “72 bytes or more”):
Every field is now labelled (Confirmed): see the peer, locate and Smart Group tables in
message format. The 6-byte MAC in the
(0,*)
frames is a target MAC that 5.0.3 always leaves zero; the sender is identified by its ESP-NOW
source address. Bonding and timing are in ESP-NOW mesh.
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,f_ble_chunking.dis:383-492 and f_ble_file_upload.dis:591-745, 1165-1300;
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 frombuff[51], anderr_noright after it.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). The payload size ismin(mtu - 3, 247) - 12— 235 bytes at MTU 256, and 8 bytes if the MTU is still 23. A write that fails withOSErroris retried up to 3 times, 160 ms apart. - App reply, written to
…-0001(v5.0.3):(0x02, 0x03)+struct '<HbBBiHiB', 18 bytes in total — the device parses it as'<BBHbBBiHiB'from offset 0 and ignores anything shorter than 18 bytes or carrying afile_idother than the one in flight. It reads file_id, status, action, chunk:status ∈ {2, 3, 4}is terminal,action 4= resume atchunk(or chunk 1 ifchunkis 0),action 1= ready. A reply longer than 50 bytes may carryerr_noafter the name, exactly as the device’s own header does.
file_type_id is not the save_to enum. SAVE_TO_VFS = 1, SAVE_TO_OTA = 2,
UPLOAD_TO_APP = 3 are internal destinations passed to FileTransfer(save_to=…) and
never appear on the wire; _upload separately sets file.file_type_id = 2, and that is
the byte you receive (Confirmed, f_ble_file_upload.dis:1495-1530).result: {} (1=done 2=no app 3=retry 4=failed 5=cancel 6=abort)):
1 done, 2 no-app, 3 retry, 4 failed, 5 cancel, 6 abort.
While an upload is streaming the uploader takes ble.noncrit_owner = 'upload', which lets
it delay the half-duplex TX handoff by up to 2 s
([send_data_v2] Non-critical hold expired ({}); handing off).