MSense reconnect behaviour (internal)ΒΆ
Internal design notes
Developer reference for how the MSense driver handles a dropped BLE link. Operator-facing guidance is in Abnormal handling β BLE drop mid-session. Severity/colour rules live in Failure levels.
All reconnect work runs on the SQC watchdog thread
(_sqc_watchdog_loop, 1 Hz), never on the BLE callback thread. The driver
never gives up on its own: a dropped wristband is retried every
RECONNECT_SWEEP_S for the life of the session. There is no distinct
"reconnect failed" status β the row stays π disconnected and only its
colour escalates.
Timeline of a single BLE drop (phase = COLLECTING)ΒΆ
| Stage | Trigger | memo.sts |
Level / colour | What PLASMA is doing |
|---|---|---|---|---|
| Drop detected | bleak disconnected_callback β _on_unexpected_disconnect(name, client) |
π disconnected |
L2 / amber | nothing yet; waits for the next sweep. The callback is guarded β it ignores a client that is not the current _live_client[name], and collapses repeats to one per DISC_LOG_DEBOUNCE_S |
| Retrying | watchdog sweep every RECONNECT_SWEEP_S = 10 s, if not p.is_connected β _reconnect_peripheral |
π disconnected (unchanged) |
L2 / amber | retire old client β (Linux) bluetoothctl remove the peer β sleep 1.5 s β fresh BleakClient β connect() (bounded 15 s) β re-negotiate MTU + re-subscribe NUS / battery / ENMO / IMU |
| Attempt fails | connect() raises or times out |
π disconnected (unchanged) |
L2 / amber | logs reconnect FAILED, returns; device stays in active_devices, retried next sweep |
| Extended outage | recorded LSL stream silent β π‘ stale, then > STALE_LOST_AGE β 30 s β health_level() returns L3, folded into the device row |
π disconnected (still unchanged) |
L3 / red | still retrying every 10 s β only the colour escalates |
| Attempt succeeds | connect() returns |
π reconnected |
NONE / green | re-subscribed; streaming resumes (gap remains in the recording) |
During SETUP (nothing recording yet) a wristband that drops and cannot reconnect stays at L2 / amber β there is no recorded stream to go stale, and nothing is being lost.
Governing constants / flagsΒΆ
| Name | Value | Where | Effect |
|---|---|---|---|
RECONNECT_SWEEP_S |
10.0 s |
msense/device.py |
watchdog reconnect-sweep cadence |
BLE_OP_TIMEOUT_S |
15.0 s |
msense/device.py |
per-call bound on disconnect() / connect() |
STALE_LOST_AGE |
30.0 s |
plasma/status.py |
stale recorded stream β L3; drives the amber β red escalation |
self.auto_reconnect |
True |
msense/device.py (instance) |
master switch for the watchdog sweep |
SQC_AUTO_RECONNECT |
True |
msense/device.py (module) |
master switch inside _reconnect_peripheral |
DISC_LOG_DEBOUNCE_S |
5.0 s |
msense/device.py |
max one disconnected unexpectedly log + memo update per wristband per window |
CLIENT_RETIRE_TIMEOUT_S |
5.0 s |
msense/device.py |
bound on the best-effort disconnect() of a client being abandoned |
Does it ever stop retrying?ΒΆ
| Condition | Retries continue? |
|---|---|
| Attempt failed once / many times | Yes β no counter, no back-off |
| Row has escalated to L3 / red | Yes β the level is display-only |
auto_reconnect or SQC_AUTO_RECONNECT set False |
No |
Session disconnect() called (sets _sqc_threads_stopped, clears active_devices) |
No β the watchdog loop exits |
Entry points that call _reconnect_peripheralΒΆ
| Caller | reason string |
Context |
|---|---|---|
| watchdog connection sweep | "connection watchdog" |
link down, checked every RECONNECT_SWEEP_S |
_sqc_recover |
"stall: <reason>" |
SQC snapshot stream wedged (no-progress timeout) |
| live-stream watchdog | "live stall" |
live-view stream stalled |
_sqc_quick_check grace finalize |
"quick-mode grace finalize" |
quick-mode STOP received no END |
reconnect_all() (manual) |
"manual reconnect" |
operator: Control β Advanced β π Reconnect now |
_reconnect_peripheral sequenceΒΆ
- Look up the current client (
active_devices[name], else_live_client[name]); bail if gone orSQC_AUTO_RECONNECTis off. _retire_client(name, old)β drop the old client's disconnect callback (_backend.set_disconnected_callback(None), best-effort), remove it from_live_client, anddisconnect()it (bounded byCLIENT_RETIRE_TIMEOUT_S). A retired client can no longer drive_on_unexpected_disconnecteven if it keeps firing._bluez_release_peer(addr)β Linux only:bluetoothctl remove <addr>sobluetoothdforgets the peer (clears any half-open ACL + stale device object + GATT cache). No-op on macOS / Windows.- Sleep 1.5 s.
- Build a fresh
BleakClient(_make_client); set_live_client[name]to it. Reusing a client across a disconnect is unreliable on CoreBluetooth, and on BlueZ a stale client keeps a live D-Bus subscription. connect()(bounded byBLE_OP_TIMEOUT_S).- On failure: log,
_retire_client(name, peripheral)(so the failed attempt doesn't leak), setmemo.sts = "π disconnected", return. The device is left inactive_devicesso the next sweep retries it. - On success: store the new client in
active_devices[name]and_live_client[name]. - Re-negotiate MTU (
_ensure_mtu), then re-subscribe: NUS, battery, and β if a recording session is running β ENMO and (when enabled) the IMU stream. - Set
memo.sts = "π reconnected".
connect_devices and disconnect() retire every client they drop, and
connect_devices clears _live_client up front β a re-scan / re-connect
from the Configuration tab never leaks the previous pass's clients.
Linux / BlueZ failure modeΒΆ
Observed on a Jetson Orin Nano (data_test/jetson, 4 wristbands, ~1 h): a
load/RF event dropped several wristbands at once, and the reconnect loop then
turned a recoverable blip into a dead Bluetooth stack that a PLASMA restart
could not clear β only a systemctl restart bluetooth / adapter power-cycle
did.
The chain
- Failed/aborted reconnects left the old
BleakClientundisposed. On BlueZ each holds a D-BusPropertiesChangedsubscription on the peer, so everyConnectedblip (and BlueZ blips repeatedly β it races its own auto-connect with PLASMA's explicitconnect()βbr-connection-canceled) fanned out as 20β40_on_unexpected_disconnectcalls per attempt for one wristband. - That storm saturated the single BLE event loop, so the next
connect()hitBLE_OP_TIMEOUT_Sβ another undisposed client + another half-open ACL β the storm grew. - Half-open ACLs / pending connections at the
bluetoothd+ kernel level outlive the PLASMA process, so a restart reconnected straight intobr-connection-canceled/ transient "device not found."
Discovery was never the problem β every scan, including after each restart, listed all four wristbands with correct addresses.
Signatures in the log
| Text | Meaning |
|---|---|
disconnected unexpectedly repeated 20β40Γ at ~0.5 s for one wristband |
the callback storm (undisposed clients) |
BLE op timed out after 15s |
connect() starved on the saturated event loop |
[org.bluez.Error.Failed] br-connection-canceled |
BlueZ already has a pending/half-open connection to that peer |
failed to discover services, device disconnected |
ACL connected, peer dropped during GATT discovery |
D-Bus call timed out |
BlueZ/D-Bus already sluggish on this host |
Mitigations now in the driver (fixes 1β3 of the consolidated proposal)
- One live client per wristband (
_live_client);_on_unexpected_disconnectignores any other β the storm cannot come from retired clients. - Debounce (
DISC_LOG_DEBOUNCE_S) β at most one log + memo update per wristband per 5 s even if the current client blips. _retire_clienton every abandoned client β its callback is dropped and its link closed, so D-Bus subscriptions and BlueZ links don't accumulate._bluez_release_peerbefore each reconnect (Linux) βbluetoothctl removeclears the half-open ACL / stale object that survived a restart.
Still on the list (not yet implemented β tiers 2β4 of the proposal):
adapter power-cycle / systemctl restart bluetooth escalation + a manual
"Reset Bluetooth" button; parallel (capped) reconnects off the watchdog thread
with back-off; stop the tight loop after ~2 min; Jetson-side power-management /
BlueZ-version / MTU tuning.