Summary
After a BLE reconnect (triggered via ConnectionManager.handle_disconnect() → _attempt_reconnect(), not the initial/explicit connect), every subsequent GATT notification from the device gets delivered twice — including single-byte protocol frames (e.g. the MESSAGES_WAITING push code and command ACK bytes), not just message content. This results in every CONTACT_MSG_RECV/CHANNEL_MSG_RECV event (and likely others) being dispatched twice for what was really one notification.
Root cause (as far as I can tell)
BLEConnection.connect() in ble_cx.py always constructs a brand-new BleakClient(...) on every call and calls start_notify() on it:
elif self.device:
self.client = BleakClient(self.device, disconnected_callback=self.handle_disconnect)
...
await self.client.start_notify(UART_TX_CHAR_UUID, self.handle_rx)
This is fine for the first connect, but on reconnect (ConnectionManager._attempt_reconnect() calling self.connection.connect() again after handle_disconnect()), the previous BleakClient instance is simply overwritten/abandoned — nothing calls .disconnect() or .stop_notify() on it first.
Contrast with BLEConnection.disconnect(), the explicit/clean path, which does properly call self.client.disconnect() before letting go of the client.
Since bleak's BlueZ backend manages D-Bus signal matching at a level that isn't strictly torn down just because a BleakClient object is dropped without an explicit disconnect, this appears to leave the old notification registration alive on the characteristic path. When the new client calls start_notify() again for the same device/characteristic, you end up with two live registrations, and BlueZ (or bleak's dispatch of the D-Bus signal) delivers every future notification to both.
Evidence
Captured with -v/DEBUG logging enabled, from a real session using create_ble() with auto_reconnect=True. A disconnect+reconnect cycle happened at 14:01:31 (BLE Connection started). Before that reconnect, notifications arrived cleanly, one D-Bus signal per notification. After it, every notification arrived as an identical duplicate D-Bus signal 1-2ms apart — including single-byte frames with no possible mesh-level ambiguity:
14:07:13,207 D-Bus PropertiesChanged: Value = b'\x11\xf4...Paradox TAG MC: Quirky'
14:07:13,208 D-Bus PropertiesChanged: Value = b'\x11\xf4...Paradox TAG MC: Quirky' <- identical, 1ms later
Same pattern for the \x83 (MESSAGES_WAITING push) and bare \n (command echo/ack) frames elsewhere in the same session — every notification duplicated, not just message payloads. This rules out a mesh-level re-delivery (which would duplicate specific broadcast packets, not literal protocol ack bytes) and points squarely at the transport layer.
I did not observe duplication immediately following an earlier reconnect in the same run (13:58:31), so it may depend on the specific disconnect reason/timing (possibly whether the BlueZ-level device connection was fully torn down before the new BleakClient.connect() ran) — but the asymmetry between the clean disconnect() path and the reconnect path's lack of any cleanup on the old client looks like the underlying issue either way.
Suggested fix
In BLEConnection.connect(), before replacing self.client with a new BleakClient, explicitly disconnect the old one if it exists (mirroring what disconnect() already does):
if self.client is not None:
try:
if self.client.is_connected:
await self.client.disconnect()
except Exception:
pass # best-effort cleanup before establishing the new client
Workaround
We've added a small de-dup guard (recent (key, timestamp) cache with a short TTL) around our own persistent mc.subscribe() handlers as a local mitigation, since get_msg()-based reads are naturally immune (its one-shot future ignores a second resolution) but direct mc.subscribe() subscribers aren't. Happy to share that snippet if useful, but the real fix belongs here.
Environment
meshcore 2.3.8
- BLE (BlueZ via
bleak) transport, Linux
auto_reconnect=True, max_reconnect_attempts well above 1
Summary
After a BLE reconnect (triggered via
ConnectionManager.handle_disconnect()→_attempt_reconnect(), not the initial/explicit connect), every subsequent GATT notification from the device gets delivered twice — including single-byte protocol frames (e.g. theMESSAGES_WAITINGpush code and command ACK bytes), not just message content. This results in everyCONTACT_MSG_RECV/CHANNEL_MSG_RECVevent (and likely others) being dispatched twice for what was really one notification.Root cause (as far as I can tell)
BLEConnection.connect()inble_cx.pyalways constructs a brand-newBleakClient(...)on every call and callsstart_notify()on it:This is fine for the first connect, but on reconnect (
ConnectionManager._attempt_reconnect()callingself.connection.connect()again afterhandle_disconnect()), the previousBleakClientinstance is simply overwritten/abandoned — nothing calls.disconnect()or.stop_notify()on it first.Contrast with
BLEConnection.disconnect(), the explicit/clean path, which does properly callself.client.disconnect()before letting go of the client.Since bleak's BlueZ backend manages D-Bus signal matching at a level that isn't strictly torn down just because a
BleakClientobject is dropped without an explicit disconnect, this appears to leave the old notification registration alive on the characteristic path. When the new client callsstart_notify()again for the same device/characteristic, you end up with two live registrations, and BlueZ (or bleak's dispatch of the D-Bus signal) delivers every future notification to both.Evidence
Captured with
-v/DEBUG logging enabled, from a real session usingcreate_ble()withauto_reconnect=True. A disconnect+reconnect cycle happened at14:01:31(BLE Connection started). Before that reconnect, notifications arrived cleanly, one D-Bus signal per notification. After it, every notification arrived as an identical duplicate D-Bus signal 1-2ms apart — including single-byte frames with no possible mesh-level ambiguity:Same pattern for the
\x83(MESSAGES_WAITINGpush) and bare\n(command echo/ack) frames elsewhere in the same session — every notification duplicated, not just message payloads. This rules out a mesh-level re-delivery (which would duplicate specific broadcast packets, not literal protocol ack bytes) and points squarely at the transport layer.I did not observe duplication immediately following an earlier reconnect in the same run (
13:58:31), so it may depend on the specific disconnect reason/timing (possibly whether the BlueZ-level device connection was fully torn down before the newBleakClient.connect()ran) — but the asymmetry between the cleandisconnect()path and the reconnect path's lack of any cleanup on the old client looks like the underlying issue either way.Suggested fix
In
BLEConnection.connect(), before replacingself.clientwith a newBleakClient, explicitly disconnect the old one if it exists (mirroring whatdisconnect()already does):Workaround
We've added a small de-dup guard (recent (key, timestamp) cache with a short TTL) around our own persistent
mc.subscribe()handlers as a local mitigation, sinceget_msg()-based reads are naturally immune (its one-shot future ignores a second resolution) but directmc.subscribe()subscribers aren't. Happy to share that snippet if useful, but the real fix belongs here.Environment
meshcore2.3.8bleak) transport, Linuxauto_reconnect=True,max_reconnect_attemptswell above 1