Skip to main content
The Totem is a BLE peripheral and nothing else. The stack is NimBLE (via MicroPython’s bluetooth), driven by f_ble/ble_lite.py (radio, advertising, IRQ), f_ble/ble_data.py (characteristics, connection state), f_ble/peripheral.py (advertising payload), ble_core.py (the GATT tree), ble_manager.py (the application protocol) and ble_controller.py (when BLE is allowed to be on).
There is no central role in the image. No frozen module calls gap_connect, gap_scan or any gattc_* method — grep the 94 disassembled modules and the only scan is wlan.scan() in BleManager.get_nearby. ota_ble.py is also a peripheral: OtaBle.start advertises as totem on the same service and waits for the app to connect and push the image (ota_ble.dis:1063-1200). Confirmed.

GATT identifiers

Custom 128-bit UUIDs recovered verbatim from the image. v5.0.3 exposes one service and three characteristics, all built in BleCore.__init__ (ble_core.dis:294-400). The third, …-0003, is new in v5.0.3; v5.0.2 builds only the first two. All three register with the same flags 0x3E = READ | WRITE_NO_RESPONSE | WRITE | NOTIFY | INDICATE (0x02 | 0x04 | 0x08 | 0x10 | 0x20). BleCore passes only service, uuid and queue_size, so BleLite.add_characteristic applies its defaults read=write=write_nr=notify=indicate=True (f_ble_ble_lite.dis:354-416) and Characteristic.__init__ ORs the matching bit for each (f_ble_ble_data.dis:274-330). No encrypted or authenticated permission variants are ORed in, and no descriptors are registered — the CCCDs are NimBLE’s own. Confirmed. Clients should scan and connect by the service UUID 7913b588-0000-4635-b066-baa2cfc197cf; the advertised local name is a constant but the GAP device name characteristic reads MPY ESP32.
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.

Value buffers

BleManager.start sizes the two GATT value buffers before launching any task (ble_manager.dis:4308-4400, Confirmed): A single client write must fit the target buffer regardless of the negotiated MTU. The value buffer does not limit the device’s own pushes: gatts_notify and gatts_indicate are always called with an explicit data argument, which NimBLE sends directly, so …-0003 needs no buffer at all. The OtaBle variant uses different sizes (92 / 64) and is the only place in the image that calls ble.config() at all — with mtu=512, nothing else (ota_ble.dis:1095-1110).

Advertising

gen_advertise_payload (f_ble/peripheral.py — the whole module is this one function plus its _append helper) appends AD structures in a fixed order and spills into the scan response only when the 31-byte advertisement is full. With the arguments ble_manager passes, the split is deterministic (Confirmed, f_ble_peripheral.dis:82-190): So a passive scan sees the name and the service UUID; the appearance needs an active scan. No manufacturer-specific data is ever advertisedmanufacturer_id lives in f_ota/config.py and device_name_sz in espnow_conn_v2.py; neither is on the BLE path. advertise is connectable=True and its own default interval is 250 ms, but every caller in the image overrides it with 50 ms. On connect the device calls gap_advertise(None, adv_data=b'', resp_data=b'') and stops advertising, so a connected Totem cannot be found by a second scanner.

When BLE is on

BLE is normally off to save power and comes up on demand. The user turns it on by double-pressing the physical power buttonsw_power.cb_double_tap = user_enable_ble (compass.dis:1377-1383); single press is toggle_brightness, hold is device_off, triple tap is unbound.
The double-press is not a toggle. user_enable_ble calls start_ble(is_user_triggered=True), and Compass.start_ble takes the enable branch whenever is_user_triggered is set, before it ever looks at ble_conn.status_id (compass.dis:6095-6130). A second double-press while the Totem is already advertising only re-launches the breathe animation; it does not turn BLE off. The status_id == 0 test is the fallback rule, used when the caller passes neither is_user_triggered nor is_force_off. Confirmed.
While advertising, the crystal breathes blue (launch_ble_breathe(animation_id=1)); on connect it blinks blue twice (animation_id=2) and the breathe task is stopped. What happens when the link drops depends on how it dropped (BleManager._on_disconnect, ble_manager.dis:1120-1327, Confirmed):
  • Graceful (ble_conn.is_graceful_disconn, set by the app’s [0x00, 0x03] write or by the device’s own stop()): BLE is disabled and stays off. Another double-press is needed.
  • Ungraceful (link loss, supervision timeout): the device launches start_reconnect as task restart_ble — see below.
Either way _on_disconnect stops all eight BLE tasks, calls b_lite.disable(), clears the TX gates and handoff bookkeeping, resets the Controller, and releases the watchdog block (wdt_mgr.set_condition(WdtConditions.BLE_ACTIVE, False)).

Auto-reconnect after an ungraceful drop

BleManager.start_reconnect(max_attempts=1) (ble_manager.dis:4704-4934, Confirmed):
  1. If the disconnect was graceful and no scheduled reconnect is pending, it bails with Compass has previously connected, exit auto-reconnect.
  2. Otherwise, while ble_conn.status_id == 0, it relaunches BleManager.start(timeout_ms=45000, is_ble_breathe=False) — a silent 45-second advertising window with no LED.
  3. It then waits up to 60 s for a connection. On the advertising window expiring it logs Timeout waiting for BLE to connect, nudges ESP-NOW back to mode 0, and sleeps a backoff of 15 s → 30 s → 60 s before the next attempt.
  4. After max_attempts it logs Reached max BLE reconnect attempts: {} and releases the watchdog block — unless BLE happens to be up, in which case Reconnect attempts ended while BLE is up - WDT stays blocked until disconnect.
Compass.start_ble passes max_attempts=3 by default, 4 for a scheduled reconnect.

Blockers

BleController (ble_controller.py) lets two subsystems veto BLE: While either flag is set the controller tears BLE down ([BleCtrl] Stopping BLE for blocker); when both clear it waits out a 10 s settle window and silently reconnects ([BleCtrl] Silently reconnecting BLE after blocker cleared). The full decode — every call site, the enable rule, the gate-by-gate burst loop, and the status_id / disconn_by value tables — is in the BLE controller below.

Scheduled disconnect and fast-reconnect bursts

Compass.ble_schedule_mgr (compass.dis:5747-6039) is a permanent task that waits on modes.evt_ble_schedule_disconn and then parks BLE for a while. Constants are literals in the function prologue (Confirmed): The sequence:
  1. [ble_schedule_mgr] BLE scheduled disconnection requested.
  2. If the BLE outbox is not empty and the link is not already flagged stalled, it waits up to 20 s (Waiting on BLE outbox to empty before scheduled disconnect), then gives up with BLE outbox not empty after {} ms (App never confirmed the connection?) - disconnecting anyway.
  3. If a log upload is in flight it waits for the uploader to go idle (Waiting on BLE log upload to finish before scheduled disconnect).
  4. If ble_conn.stall_count >= 2 it logs BLE hung {} links in a row - staying off {} ms without fast-reconnect bursts and uses the 90 s window with no bursts; otherwise 30 s with bursts.
  5. If the battery is below the BLE threshold it logs Battery too low for BLE and just turns BLE off (start_ble(is_force_off=True)) with no reconnect promise.
  6. Otherwise it calls start_ble(is_force_off=True, reconn_start=<window>, reconn_duration=10000) — which makes BleManager.stop emit the [0x00, 0x05] frame below — sleeps out the window, and then launches start_ble(is_reconn=True, max_attempts=4, conn_mode=1) (Launching reconnect).
During the window BleController.ble_check_loop(window_ms=…) advertises a 1.2 s connectable burst every ~8 s, aligned to the ESP-NOW radio timer and stopping 5 s before the window ends. Each burst is gated on: the scheduled disconnect still pending, ble_conn.status_id == 0, disconn_by != 1, not powering down, no blockers, modes.evt_bonded set, and modes.evt_battery_charged set. A burst that connects logs [BLE Check] App connected via fast reconnect; one that does not logs [BLE Check] No connection - quietly turning BLE back off from burst_cleanup, which also stops every BLE task, sets b_lite.is_exiting, stops advertising, calls disable() and clears the watchdog block. Confirmed. The 15-second hung-link guard (below) is what feeds evt_ble_schedule_disconn when an app connects and then never says anything. [ESP-NOW] | Requesting BLE Scheduled Disconnect and [ESP-NOW] | BLE Auto Reconnect is disabled in App are the mesh-side triggers for the same event.

Connection handshake

No pairing, bonding, or encryption is required. The characteristics register with plain flags 0x3E; the only ble.config() call in the whole image is ota_ble’s mtu=512, so no bond, mitm, le_secure or io option is ever set (f_ble_ble_lite.dis:1105-1144BleLite.enable calls ble.active(True) and nothing else). The IRQ handler has no passkey or encryption-update branch. An unpaired custom central can read, write, and subscribe — the only gate to data flow is the application-layer handshake below. Confirmed.
The IRQ handler BleLite.irq_cb (f_ble_ble_lite.dis:807-1020) implements exactly these events (Confirmed): 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 characteristics.
2

MTU exchange

Entirely client-driven — the device never calls gattc_exchange_mtu and never sets a preferred MTU on the app path. It starts at 23 and adopts whatever _IRQ_MTU_EXCHANGED reports. Aim for ≥ 188 so a full data frame fits one PDU: the data value buffer is 185 bytes. The MTU also sets the upload chunk size, min(mtu - 3, 247) - 12.
3

Subscribe

Enable notifications and indications on …-0001 and …-0002 (and …-0003 if you accept uploads). The device pushes on conn handle 0 — both gatts_notify and gatts_indicate are called with a literal 0, so it only ever talks to one 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, and after 15 s it schedules a disconnect.
5

Data transfer

Messages flow on chars__data_transfer (…-0002) as (cat_id, cmd_id) records (Static, Live, Peer). See message format.
Right after the connection is established, BleManager.start also (Confirmed, ble_manager.dis:4550-4620):
  • resets both characteristic inboxes;
  • requests a connection-parameter update: gap_conn_param_update(0, 10000, 20000, 0, 12000) — a 10–20 ms connection interval, slave latency 0, 12 s supervision timeout (Requesting Gap Conn Params Update);
  • persists the bonding secret with save_sess();
  • sets modes.has_ble_connected and modes.is_rtc_snapshot.

ConnStatus Ready frame

To unblock app-data flow the client writes the Ready frame to chars__conn_status (…-0001). Parsed in BleManager.recv_status_msgs (ble_manager.dis:2753-3119, Confirmed): [0x00, 0x01, conn_mode, frame_schema_id] or, since v5.0.3, [0x00, 0x01, conn_mode, frame_schema_id, caps]
TX ownership needs both fields. evt_is_tx_owner is set only inside the frame_schema_id > 0 branch, and only when conn_mode == 1 as well. A Ready frame with conn_mode = 1 and schema 0 clears the pending record ids but grants nothing — which is correct, because schema 0 runs the legacy loop where ownership is unused.
Every cat-0 write also assigns controller.status__conn = cmd_id, whatever the command is. v5.0.3 logs the frame as === BLE Connection Mode: {} | FrameSchemaId: {} | UploadSupported: {} (only when ble_conn.is_debug) and always logs [Upload] App conn frame: {} B | upload support: {}. Hung-link guard. The legacy transmit loop watches controller.status__conn. If it is anything other than 1 for 15 s, the device increments ble_conn.stall_count, logs App never sent ConnStatus within {} ms - link treated as hung (stall #{}); scheduling a disconnect, and sets modes.evt_ble_schedule_disconn — it does not drop the link itself; the scheduled-disconnect manager does. The check then repeats every 15 s with Hung link still up (no ConnStatus) - re-requesting the scheduled disconnect. Two hung links in a row push the off window to 90 s and suppress the fast-reconnect bursts. (ble_manager.dis:3840-3890, Confirmed.)
A (0x0C, 0x03) phone-fix frame on …-0002 is a side door out of this state: if status__conn != 1 when one arrives, the device logs App failed to send status_conn, manually setting to 1 and sets status__conn = 1, modes.is_app_conn = True, peer_cmd_id = 0 (ble_manager.dis:2580-2600). Confirmed — but send the Ready frame anyway; nothing else configures the transmit loop.

Other client → device frames on …-0001

Device → client frames on …-0001

BleManager.stop(reconn_start, reconn_duration) builds a 10-byte frame and, while status__conn == 1, sends it three times, 50 ms apart, before tearing the link down (ble_manager.dis:4934-5084, Confirmed):

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. BleManager.start launches five tasks in all: ble_recv_status, ble_recv_data, ble_send_data, ble_send_data_v2 and ble_file_upload (plus ble_advertise when not bursting, and ble_handoff_wd after each handoff).
The legacy loop always notifies — it never indicates, and it never updates the readable value. Characteristic.write(data, send_update=False) (f_ble_ble_data.dis:528-563) does nothing unless send_update is truthy, and then branches on self.is_indicate, which is True for all three characteristics. So it always takes the ble.gatts_notify(0, handle, data) path; the gatts_write branch is dead code in this firmware. A client that polls the characteristic with a GATT read will never see application data — you must subscribe. Confirmed.
Legacy loop pacing (send_data, ble_manager.dis:3768-4211, Confirmed). The loop sleeps delay ms per pass, starting at 200 ms, becoming 750 ms after a Peer Sync and 1000 ms once Live Data starts flowing; a Static Data send while delay is still 200 adds an extra 700 ms sleep. Live Data goes out on every third pass — toggle_3_call() yields 1, 0, 0 forever — so ~3 s apart at the settled cadence. Legacy acks. The loop repeats a record every pass until the app acknowledges it with a data frame on …-0002 (recv_data_msgs assigns controller.<cat>_cmd_id = cmd_id verbatim, so any command in a category clears its pending request): Controller.__init__ and Controller.reset both leave peer_cmd_id = 1 with everything else 0, so a Peer Sync is pending from the start — the app does not have to ask for the peer list, only to acknowledge Static Data first. Peer Pings are sent one per pass from peer_outbox, and only while the last Live Data was less than 10 s ago. After 10 s of connection the loop also paces itself behind ESP-NOW (Wait for ESP-NOW Comms to be sent before sending BLE message, then a 100 ms settle and Good to send BLE).
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 is a plain notification, 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, send_data_v2 sets evt_tx_idle, builds the handoff frame with gen_comms_handoff and indicates it on …-0001:
Only once the indication is confirmed does it clear both gates, set handoff_cooldown = ticks_add(now, 4000), record last_handoff_to_app, and launch ble_handoff_wd. Because the next handoff needs ticks_diff(now, handoff_cooldown) >= 4000, the device hands off at most every ~8 s. Before handing off it will also delay up to 2 s for a non-critical owner such as the file uploader (noncrit_owner), logging [send_data_v2] Non-critical hold expired ({}); handing off. (ble_manager.dis:3119-3768, Confirmed.) The app gives ownership back by writing [0x04, 0x03, flags] to …-0001:
  • bit 1 grants — sets both gates, stops the ble_handoff_wd task, and records last_handoff_to_esp = ticks_ms();
  • bit 0 revokes — clears evt_is_tx_owner, evt_is_tx_ready and evt_tx_idle.
Both bits are read independently, and revoke is applied before grant. The watchdog (ble_handoff_watchdog, ble_manager.dis:1937-2070, Confirmed) sleeps 15 s, then exits immediately if the link is closing, if the schema went back to 0, if the device already owns TX, or if no handoff has happened. It calls the link stalled when the app has not handed TX back since the last handoff to it (last_handoff_to_esp <= last_handoff_to_app). Even then it grants a reprieve: if the app wrote anything in the last 5 s and the handoff is less than 60 s old, it sleeps another 5 s and re-checks. Otherwise it logs [BLE Watchdog] Handoff stall detected — reclaiming TX priority, sets both gates and restarts the 4 s cooldown.

Keys & bonding

Bonding is optional and not required for the phone-app link. The firmware never initiates pairing and never requires an encrypted link — an unpaired central works. The infrastructure below only persists a reconnect secret if the central chooses to bond.
The IRQ handler does implement the NimBLE secret store (Confirmed, f_ble_ble_lite.dis:900-1000):
  • _IRQ_SET_SECRET (30) clears b_lite.secrets and appends a single (sec_type, bytes(key), bytes(value)) tuple — the device remembers exactly one bond.
  • _IRQ_GET_SECRET (29) returns secrets[0][2] when the key is None, else scans the list for a matching key.
BleCore.save_sess (called on every successful connection) serialises that one secret into a 9-byte header plus key and value — magic 0xA7 0x74, category 1, lengths at offsets 7 and 8 — and stores it in RTC memory category 1 (Saving BLE secrets to rtc memory). BleCore.reload_sess reads it back and replays it through b_lite.add_secret. device_power.restore_ble_secrets_vfs / save_ble_secrets mirror the same blob to ble_keys.bin on the VFS so the bond survives a power cycle, not just a reset (Saving BLE secrets to VFS, BLE secrets found in RTC memory, saving to VFS, Checking for BLE secrets on VFS). compass.py logs Can't load BLE secrets when that fails. The gap_pair, gap_passkey, is_pairing, cancel_pairing symbols exist in the image but are never invoked from the app connect path. 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.

Watchdog interaction

BleManager.start sets wdt_mgr.set_condition(WdtConditions.BLE_ACTIVE, True) as its very first action, and the condition is cleared in _on_disconnect, in burst_cleanup, and at the end of start_reconnect when BLE really is down. While BLE is up the hardware watchdog is therefore blocked — which is why a hung link that the app never confirms is treated as a fault worth scheduling a disconnect for. Confirmed.

The BLE controller

ble_controller.py decides when BLE is allowed to be on. It is a small module: a constant holder BleBlockers, a class BleController, and a module-level singleton ble_ctrl = BleController() built at import time (ble_controller.dis:166-178). Exactly two modules import it, both as from ble_controller import ble_ctrl, BleBlockerscompass.py (compass.dis:1024-1033) and espnow_conn_v2.py (espnow_conn_v2.dis:747-756). Confirmed.
BleCtrl is a log-line prefix, not a class. The module defines two classes and nothing else: BleBlockers and BleController.

Wiring, and why it starts inert

BleController.__init__ (ble_controller.dis:238-277, Confirmed) sets ten attributes and does nothing else: Two of those are dead. _cb_wlan_mode is assigned by Compass.start_ble and never read: ble_check_loop reaches ESP-NOW through its own from espnow_conn_v2 import enow_v2, radio_timer instead. is_user_disconn is written once in __init__ and appears nowhere else in any of the 94 modules. Confirmed. Because all three live wires are attached inside Compass.start_ble, the controller does nothing at all until BLE has been started at least once — both update() and ble_check_loop() return immediately while self.ble is None (ble_controller.dis:387-393, ble_controller.dis:511-517).

The blocker engine

BleBlockers is two integers and no methods (ble_controller.dis:182-197): BONDING = 0, SMART_GRP = 1. _should_enable is a single expression (ble_controller.dis:743-748, Confirmed):
This is a different shape from wdt_manager._should_enable. That one calls _has_active_blocks(), records the answer in self.is_blocking_tasks as a side effect, and then walks a second conditions list, returning False if any entry is truthy (wdt_manager.dis, simple_name: _should_enable). BleController has no conditions list and no side effect — BLE is allowed exactly when both blocker flags are False. The watchdog’s “zero blockers and all conditions false” rule does not carry over. Confirmed.
Every ble_ctrl.set_block call site in the image (Confirmed; the other set_block hits in compass.dis and espnow_conn_v2.dis belong to wdt_mgr, not to this controller): set_block(block_index, is_blocked) (ble_controller.dis:331-381) logs [BleCtrl] Setting BLE block to: {} | for index: {}, writes self.blockers[block_index], and — only when setting a block — calls set_reconn_params(app_delay=5000, app_duration=10000, attempts=4) before calling update(). Two quirks are visible in the bytecode: it computes any(self.blockers) into a local before the write and never reads it (a dead store, ble_controller.dis:345-349), and the set_reconn_params call is guarded by block_index == 0 or block_index == 1 (ble_controller.dis:357-364), which is always true for a two-element list. Confirmed. set_reconn_params(app_delay=0, app_duration=0, attempts=10) has no caller outside set_block, so its attempts=10 default is never used; reconn_attempts is 4 from __init__ and 4 from every real call. Confirmed. update() (ble_controller.dis:382-438) is the whole decision:
no_blocks_ticks records when the last blocker cleared; the if not guard stops a repeated clear from pushing the timestamp forward. tasks.launch de-duplicates by task_name (f_lib_task_mgr.dis, simple_name: launch), so neither task can run twice concurrently. release_all_blocks() (ble_controller.dis:278-313) zeroes both flags — and does not call update(). Its only caller is Compass.user_enable_ble, the physical double-press handler, which stops the ble_check task, clears modes.is_silent_reconn, calls release_all_blocks() and then launches start_ble(is_user_triggered=True) itself (compass.dis:6039-6073). Confirmed.
The power-button double-press overrides both vetoes. A Totem the mesh has parked for smart-group formation or peer bonding still comes up advertising on a double-press, with the blockers discarded rather than re-evaluated. Nothing restores them: they come back only when the bonding or smart-group path calls set_block(..., True) again. Combined with the fact that the link itself requires no pairing or encryption, physical access to the button is enough to put a Totem on the air whatever the mesh had decided. Confirmed (compass.dis:6039-6073 and ble_controller.dis:278-313).
debug() (ble_controller.dis:439-473) prints [BleCtrl {}] blocks=[{}] with the builtin print rather than the logger, and has no caller anywhere in the image — a development helper left in the frozen build. Confirmed.

status_id and disconn_by

Every controller gate reads these two ble_conn fields, and both are fully recoverable. status_id is a property over _status_id, written only by Conn.set_conn_status, which indexes a literal name tuple for the log line [set_conn_status] BLE {} (f_ble_ble_data.dis:656-745, Confirmed):
ble_conn.status_id is not Controller.status__conn. The latter is the app’s ConnStatus command id on the ble_manager side (see ConnStatus Ready frame); ble_controller.py never reads or writes it.
disconn_by records who ended the last link. All four writers (Confirmed): That is what makes _start_ble’s refusal legible: it declines on 1 and 3 — the app walked away, or never arrived — but deliberately not on 2, which is the link the controller itself tore down and the one it wants back.

_start_ble

Launched by update() as task ble_ctrl_start (ble_controller.dis:821-924, Confirmed):
  1. Logs [BleCtrl] Starting BLE.
  2. If no_blocks_ticks is set, sleeps out the remainder of a 10 000 ms settle window measured from the moment the last blocker cleared.
  3. Re-checks _should_enable() and returns if a blocker reappeared during that sleep. (stop_ble also calls tasks.stop('ble_ctrl_start'), so the usual outcome is cancellation; this re-check covers the race.)
  4. If status_id == 0 and disconn_by in (1, 3), logs [BleCtrl] Last BLE conn timed out or was disabled by user and gives up. Note the conjunction: when status_id is anything other than 0, this refusal is skipped entirely and the reconnect goes ahead.
  5. If _cb_start_ble is None, logs [BleCtrl] No start_ble callback set; cannot reconnect and gives up.
  6. Otherwise logs [BleCtrl] Silently reconnecting BLE after blocker cleared, sets ble_conn.is_scheduled_reconn = True and modes.is_silent_reconn = True, and awaits Compass.start_ble(is_reconn=True, max_attempts=self.reconn_attempts, conn_mode=1).

stop_ble

Launched by update() as task ble_ctrl_stop; its signature takes **kwargs (ble_controller.dis:750-820, Confirmed):
  1. Logs [BleCtrl] Stopping BLE for blocker.
  2. Returns immediately if self.ble is unset.
  3. Cancels four tasks in order — start_ble, restart_ble, ble_ctrl_start, ble_check — so a blocker kills a pending controller start, an auto-reconnect and a fast-reconnect burst loop as well as the main start.
  4. Returns without touching the radio if ble_conn.status_id in (0, 1): there is nothing to tear down when the link is already Not Connected or Disconnecting, and in that case the app is never sent a reconnect promise.
  5. Otherwise awaits BleManager.stop(reconn_start=..., reconn_duration=...), taking the values from kwargs and falling back to app_reconn_delay / app_reconn_duration. That is what emits the [0x00, 0x05] scheduled-disconnect frame.
The only call site passes no kwargs beyond task_name (which tasks.launch consumes), so in this firmware the two values always come from the attributes — the 5000 / 10000 that set_block wrote a moment earlier. Confirmed.

ble_check_loop, gate by gate

The 1.2 s-burst-every-8 s behaviour is described under scheduled disconnect. What reading the bytecode adds is that the gates are not equivalent (ble_controller.dis:496-737, Confirmed). Compass.ble_schedule_mgr launches it as task ble_check with window_ms set to the off window, 30 000 or 90 000 ms (compass.dis:5972-5981); the signature’s own default is window_ms=30000. On each pass, after sleeping to the next 8 s tick and re-arming it: So a blocker, an unbonded device or a flat battery suppresses only individual bursts — the loop keeps running and bursts again as soon as the condition lifts. The five state checks above them end the loop for good. Between the tick and the gates, when modes.evt_rtc_ready is set, the loop aligns itself to the mesh radio: it sleeps ticks_diff(radio_timer(mpm=15), ticks_ms()) + 500 and then awaits modes.evt_comms_sent.wait() (ble_controller.dis:574-607). A burst sets modes.is_silent_reconn = True and self.ble.conn_mode = 0, powers ESP-NOW on if it was off, awaits enow_v2.check_mode(mode_id=2), then awaits BleManager.start(burst_ms=1200, is_ble_breathe=False) inside a try. On asyncio.CancelledError it calls self.ble.burst_cleanup() only when status_id != 3 — a burst that actually connected is left alone — and re-raises. If the start returns truthy it logs [BLE Check] App connected via fast reconnect and returns; otherwise it clears is_silent_reconn, calls enow_v2.power_off_soft() if ESP-NOW had been off, and loops.
Every return in ble_check_loop returns True — the successful fast reconnect, the window expiring and every state exit alike — so the coroutine’s result carries no information. Nothing awaits it: tasks.launch wraps it in asyncio.create_task. Confirmed.

v5.0.3 vs v5.0.2: no functional change

ble_controller.mpy does differ between the two images (MD5 1f69ca62…5c0b59be…), which is why the module diff counts it among the 21 changed modules. But the difference is entirely source line numbers. Stripping the line info and raw bytecode lines from both disassemblies leaves two byte-for-byte identical files: the 105-entry qstr table, the 17-entry object table, every prelude, and every decoded instruction across all 14 code objects match exactly. Every line-info entry that changed did so by exactly +2 lines, at the same position in each function’s table, and the shift does not compound. Decoding the module-level table gives: Two source lines — blank or comment — were inserted between the import block and class BleBlockers, and nothing else in the module changed. ble_controller.py behaves identically in v5.0.2 and v5.0.3, so nothing on this page is version-dependent. Confirmed (re/v5.0.2/mpy/ble_controller.dis vs re/v5.0.3/mpy/ble_controller.dis).

What is not recoverable

The frozen bytecode carries no comments, docstrings or original blank lines, so the two lines added in v5.0.3 cannot be read — only their existence and position. Local variable names inside every method are also gone (the disassembly shows LOAD_FAST 4, not a name); the names used above for locals are descriptions, not recovered identifiers. Argument and attribute names survive because they are qstrs, and all of those are quoted verbatim.

OTA over BLE

ota_ble.py carries a firmware image over the same GATT service using the chunking layouts: OtaBle rebuilds BleCore, advertises as totem with a 60 s window, raises the MTU with ble.config(mtu=512), sizes its buffers to 92 / 64, and receives chunks into a FileTransfer(save_to=SAVE_TO_OTA) or SAVE_TO_VFS (Starting OTA Bluetooth connection, Current MTU: {}, Connected to OTA BLE, Received request for file transfer). 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. svc_ble_transfer.py is the service wrapper that runs that mode: it instantiates OtaBle, enables Bluetooth and watches memory (Running BLE Transfer, Enable Bluetooth), rather than implementing the transfer itself. A Totem can also trigger OTA on nearby peers over the 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). The app asks for that with a (0x02, 0x05) write on the data characteristic.