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 inBleCore.__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 advertised —
manufacturer_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 button —sw_power.cb_double_tap = user_enable_ble
(compass.dis:1377-1383); single press is toggle_brightness, hold is device_off,
triple tap is unbound.
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 ownstop()): BLE is disabled and stays off. Another double-press is needed. - Ungraceful (link loss, supervision timeout): the device launches
start_reconnectas taskrestart_ble— see below.
_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):
- If the disconnect was graceful and no scheduled reconnect is pending, it bails with
Compass has previously connected, exit auto-reconnect. - Otherwise, while
ble_conn.status_id == 0, it relaunchesBleManager.start(timeout_ms=45000, is_ble_breathe=False)— a silent 45-second advertising window with no LED. - 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. - After
max_attemptsit logsReached max BLE reconnect attempts: {}and releases the watchdog block — unless BLE happens to be up, in which caseReconnect 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:
[ble_schedule_mgr] BLE scheduled disconnection requested.- 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 withBLE outbox not empty after {} ms (App never confirmed the connection?) - disconnecting anyway. - 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). - If
ble_conn.stall_count >= 2it logsBLE hung {} links in a row - staying off {} ms without fast-reconnect burstsand uses the 90 s window with no bursts; otherwise 30 s with bursts. - If the battery is below the BLE threshold it logs
Battery too low for BLEand just turns BLE off (start_ble(is_force_off=True)) with no reconnect promise. - Otherwise it calls
start_ble(is_force_off=True, reconn_start=<window>, reconn_duration=10000)— which makesBleManager.stopemit the[0x00, 0x05]frame below — sleeps out the window, and then launchesstart_ble(is_reconn=True, max_attempts=4, conn_mode=1)(Launching reconnect).
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-1144 — BleLite.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.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.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_connectedandmodes.is_rtc_snapshot.
ConnStatus Ready frame
To unblock app-data flow the client writes the Ready frame tochars__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]
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).
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 & 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:
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_wdtask, and recordslast_handoff_to_esp = ticks_ms(); - bit 0 revokes — clears
evt_is_tx_owner,evt_is_tx_readyandevt_tx_idle.
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
The IRQ handler does implement the NimBLE secret store (Confirmed,f_ble_ble_lite.dis:900-1000):
_IRQ_SET_SECRET(30) clearsb_lite.secretsand appends a single(sec_type, bytes(key), bytes(value))tuple — the device remembers exactly one bond._IRQ_GET_SECRET(29) returnssecrets[0][2]when the key isNone, 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, BleBlockers —
compass.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.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.
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):
- Logs
[BleCtrl] Starting BLE. - If
no_blocks_ticksis set, sleeps out the remainder of a 10 000 ms settle window measured from the moment the last blocker cleared. - Re-checks
_should_enable()and returns if a blocker reappeared during that sleep. (stop_blealso callstasks.stop('ble_ctrl_start'), so the usual outcome is cancellation; this re-check covers the race.) - If
status_id == 0anddisconn_by in (1, 3), logs[BleCtrl] Last BLE conn timed out or was disabled by userand gives up. Note the conjunction: whenstatus_idis anything other than0, this refusal is skipped entirely and the reconnect goes ahead. - If
_cb_start_bleisNone, logs[BleCtrl] No start_ble callback set; cannot reconnectand gives up. - Otherwise logs
[BleCtrl] Silently reconnecting BLE after blocker cleared, setsble_conn.is_scheduled_reconn = Trueandmodes.is_silent_reconn = True, and awaitsCompass.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):
- Logs
[BleCtrl] Stopping BLE for blocker. - Returns immediately if
self.bleis unset. - 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. - 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. - Otherwise awaits
BleManager.stop(reconn_start=..., reconn_duration=...), taking the values fromkwargsand falling back toapp_reconn_delay/app_reconn_duration. That is what emits the[0x00, 0x05]scheduled-disconnect frame.
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 showsLOAD_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.