Skip to main content
The Totem is a BLE peripheral for the phone-app link (the app is the central), but the firmware also compiles the central role and uses it for some flows such as the OTA-BLE client (Starting OTA Bluetooth connection). The stack is NimBLE (via MicroPython’s bluetooth), driven by f_ble/peripheral.py, ble_controller.py, ble_core.py, and ble_manager.py.

GATT identifiers

Custom 128-bit UUIDs recovered verbatim from the image. v5.0.3 exposes one service and three characteristics (ble_core.py BleCore; f_ble/ble_data.py sets the flags). The third, …-0003, is new in v5.0.3; v5.0.2 builds only the first two: All characteristics register with the same flags 0x3E = READ | WRITE_NO_RESPONSE | WRITE | NOTIFY | INDICATE (0x02 | 0x04 | 0x08 | 0x10 | 0x20; add_characteristic in f_ble/ble_lite.py passes range/values/write_nr/notify/indicate all defaulting to True, and Characteristic.__init__ ORs the matching bit for each; no encrypted/authenticated permission variants are ORed in). Clients should scan and connect by the service UUID 7913b588-0000-4635-b066-baa2cfc197cf — the advertised local name is built at runtime and should not be relied on. Transfer is implemented in svc_ble_transfer.py.
In v5.0.2, chars__on_demand was referenced by f_ble/file_upload.py but never created, and nothing imported the uploader: BLE log upload was dead code. v5.0.3 creates …-0003 in BleCore.__init__, wires FileUploader into ble_manager, and streams upload chunks on it (FileUploader._stream is its only user). A client that does not accept uploads can ignore the characteristic; see file transfer.
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 also appears in the image but is not a BLE UUID — it is the RFC 6455 WebSocket handshake magic GUID used by MicroPython’s WebREPL. Every Totem BLE UUID is in the 7913b588 family.

Advertising

BleManager.start advertises with name='totem', appearance=1361 and services=[<Totem service>] (the full 128-bit UUID; fields that do not fit the 31-byte advertisement spill into the scan response). A scanner can match on either the name or the service UUID. The GAP device name characteristic reads MPY ESP32. BLE is normally off to save power and comes up on demand. The user turns it on by double-pressing the physical power button: sw_power.cb_double_tap = user_enable_ble (single press toggles brightness, hold powers off). Compass.start_ble is a toggle: it enables BLE when ble_conn.status_id == 0 and disables it otherwise. While advertising, the crystal breathes blue (launch_ble_breathe(animation_id=1)). After a session ends, _on_disconnect disables BLE again, so every new session needs another double-press unless the scheduled-reconnect machinery below brings it back. The controller logic (BleCtrl) manages this: (BleCtrl) manages this:
A blocker system (BleBlockers, Setting BLE block to: {} | for index: {}) lets subsystems veto BLE while they need the radio, then BLE silently reconnects when the last blocker clears. A separate scheduled-disconnect manager (ble_schedule_mgr, evt_ble_schedule_disconn) governs the on-demand connect/disconnect lifecycle ([ble_schedule_mgr] BLE scheduled disconnection requested, [ESP-NOW] | Requesting BLE Scheduled Disconnect), honouring [ESP-NOW] | BLE Auto Reconnect is disabled in App.

Connection handshake

No pairing, bonding, or encryption is required. The characteristics register with plain flags 0x3E only; the firmware never calls ble.config() with any security option (no bond / mitm / le_secure / io_capability) and the IRQ handler has no passkey or encryption events. An unpaired custom central can read, write, and subscribe — the only gate to data flow is the application-layer handshake below.
Once connected, the two sides negotiate before app data flows:
1

Connect by service UUID

Scan for and connect to 7913b588-0000-4635-b066-baa2cfc197cf; discover its two characteristics.
2

MTU exchange

Current MTU: {} — client-negotiated; aim for ≥ ~188 so a full data frame fits in one PDU (the data value buffer is 185 bytes; larger payloads use the chunking protocol). Determines chunk size for the transfer layer.
3

Subscribe

Enable notifications / indications so the device can push data (gatts_notify / gatts_indicate on conn handle 0 — the app is the only central).
4

Send the ConnStatus Ready frame

Write the 4-byte frame to chars__conn_status (…-0001) — see below. Until it arrives the device holds off: ConnStatus Ready command not yet received, not sending BLE updates. Logged as === BLE Connection Mode: {} | FrameSchemaId: {}.
5

Data transfer

Messages flow on chars__data_transfer (…-0002) as (cat_id, cmd_id) records (Static, Live, Peer). See message format.

ConnStatus Ready frame

To unblock app-data flow the client writes the Ready frame to chars__conn_status (…-0001): [0x00, 0x01, conn_mode, frame_schema_id] or, since v5.0.3, [0x00, 0x01, conn_mode, frame_schema_id, caps] v5.0.3 logs the frame as === BLE Connection Mode: {} | FrameSchemaId: {} | UploadSupported: {}. It also adds a hung-link guard: if the app has not sent the Ready frame within 15 s of connecting, the device logs App never sent ConnStatus within {} ms - link treated as hung (stall #{}); scheduling a disconnect and drops the link. To end the session, write [0x00, 0x03] — a graceful-disconnect request (sets ble_conn.is_graceful_disconn). The device announces its own disconnects on …-0001 as [0x00, 0x02], or [0x00, 0x05] + <ii when it schedules a reconnect.

Transmit modes

ble_manager launches two transmit tasks on every connection, and frame_schema_id decides which one runs. Both exist unchanged in v5.0.2 and v5.0.3. Legacy acks. The legacy loop repeats a record every pass until the app acknowledges it with a data frame on …-0002: Peer Pings are sent once each from the outbox (one per pass), and after 10 s of connection the loop paces itself behind ESP-NOW (Wait for ESP-NOW Comms to be sent before sending BLE message).
Half duplex stalls on Apple platforms. Both data characteristics support notify and indicate, and CoreBluetooth then always subscribes for notifications only (Apple DTS). The device still sends indications and treats any record not confirmed within 500 ms as lost. On a Mac the first record (here a Peer Sync) was retried ~60 times in 30 s, the device never handed TX back, and the session never progressed. The macOS Bluetooth log (bluetoothd) shows every indication dispatched but no confirmation. The legacy loop only uses send_update, which honours the notification subscription, and works on the same Mac.

Half-duplex & comms handoff

In half-duplex mode the link has an explicit transmit owner: The BLE link to the app and the ESP-NOW mesh share a single radio on the device, so transmit ownership alternates between them. After sending Static Data, Peer Sync, Live Data, the WiFi list and queued Peer Pings, the device writes a handoff frame on …-0001, [0x04, 0x02, 0x02, 0×9] (12 bytes, flags bit 1 = TX to app), and clears both gates. It hands off at most every ~8 s (handoff_cooldown = 4 s after the last handoff, plus a 4 s gap). v5.0.3 adds evt_tx_idle and a non-critical hold: a non-critical owner such as the file uploader can delay the handoff by up to 2 s ([send_data_v2] Non-critical hold expired ({}); handing off). The app gives ownership back by writing [0x04, 0x03, flags] to …-0001: bit 1 grants (sets both gates) and bit 0 revokes (clears both; v5.0.3 also clears evt_tx_idle). recv_status_msgs steps the ble_handoff_wd watchdog on each grant. The watchdog runs every 15 s and reclaims TX for the device if the app has held it without writing anything for 5 s.

Keys & bonding

Bonding is optional and not required for the phone-app link. The firmware never forces pairing on the app connection — an unpaired central works. The infrastructure below only persists optional reconnect secrets.
BLE bonding keys are persisted in ble_keys.bin. Secrets are cached in RTC memory and written through to VFS in a two-tier restore (Saving BLE secrets to rtc memory, Saving BLE secrets to VFS, BLE secrets found in RTC memory, saving to VFS, Checking for BLE secrets on VFS; save_ble_secrets, restore_ble_secrets_vfs, add_secret), so bonds can survive resets when used. The gap_pair, gap_passkey, is_pairing, cancel_pairing symbols exist in the image but are never invoked from the app connect path (the IRQ handler has no passkey/encryption branch). The separate add_new_bond / create_promo_bond symbols belong to the Totem-to-Totem peer auto-bonding mechanism ([create_promo_bond] Created Bond!, Creating promo activation bond, is_promo_bond) — see the mesh peer bonding page, not app pairing.

OTA over BLE

ota_ble.py + svc_ble_transfer.py carry a firmware image over the same chunked GATT transport for app-driven Bluetooth updates (Starting OTA Bluetooth connection, Connected to OTA BLE, cb_start_ota, close_ota). This is the live “update via the app” path. The legacy WiFi-hotspot OTA path has been retired (OTA Hotspot has been sunset) and is no longer a live fallback. See OTA. A Totem can also trigger OTA on nearby peers over the BLE/ESP-NOW mesh — the “demigod” fleet push-OTA (Sending OTA command to update nearby devices, Demigod to update nearby devices; demigod_gen_ota_update, enable_demi_daemon, demi_god.py).