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:
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.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 tochars__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 & 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
BLE bonding keys are persisted inble_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).