BLE Transport: The Realities of Bluetooth on Mobile
Building a BLE transport that works reliably on both iOS and Android is harder than it looks. Here is what we learned in the first week.
The Connection Sequence
Connecting to a Meshtastic device over BLE is not just “connect and send.” There is a specific sequence:
- Scan for devices advertising the Meshtastic service UUID
- Connect to the selected peripheral
- Discover services and characteristics
- Subscribe to notifications on the FromRadio characteristic
- Send want_config to trigger the config download
- Wait for config_complete before sending any other commands
- Enable notifications for ongoing data
If you skip step 5 or send commands before step 6 completes, the device silently ignores you. No error, no response. Just silence.
Adapter State Handling
The BLE adapter can be in several states, and not all of them mean “Bluetooth is off”:
unknown— iOS returns this briefly at app launch before the system resolves the actual stateresetting— the adapter is restarting (happens after airplane mode toggle)unsupported— hardware does not support BLEunauthorized— user denied Bluetooth permissionpoweredOff— Bluetooth is disabledpoweredOn— ready to scan
The trap: starting a scan during unknown or resetting throws an error on some Android devices but silently fails on iOS. Our solution is a retry loop that waits for poweredOn before attempting any scan:
Future<void> startScanWithRetry() async {
for (int attempt = 0; attempt < 3; attempt++) {
final state = await FlutterBluePlus.adapterState.first;
if (state == BluetoothAdapterState.on) {
await FlutterBluePlus.startScan(timeout: scanTimeout);
return;
}
await Future.delayed(const Duration(seconds: 1));
}
throw BleAdapterNotReadyException();
}
Device Wake Sequence
Meshtastic devices can enter deep sleep. When you first connect, the device might not respond to config requests because it has not fully woken up. We added a wake sequence that sends a small probe packet before requesting configuration:
Future<void> wakeDevice() async {
// Send a heartbeat to wake the device from sleep
final heartbeat = ToRadio(heartbeat: Heartbeat());
await transport.sendBytes(heartbeat.writeToBuffer());
await Future.delayed(const Duration(milliseconds: 500));
}
The 500ms delay is empirical. Some devices need it, some do not. But without it, roughly 1 in 5 connections fail on the first config request.
The Framing Problem
BLE has a maximum transmission unit (MTU) that limits packet size. Meshtastic uses protobufs that can exceed the MTU. The BLE stack handles fragmentation transparently — you write a large byte array and the stack splits it into MTU-sized chunks.
USB serial does not have this luxury. It needs explicit framing: start marker, length prefix, payload, CRC. This is why the requiresFraming flag exists on the transport interface. The protocol layer assembles complete protobufs regardless of the physical transport.
BLE: [raw protobuf bytes] → protocol layer
USB: [0x94 0xC3] [length] [protobuf bytes] [CRC] → deframe → protocol layer
Both paths deliver the same Uint8List to the protocol service. The framing is invisible above the transport layer.
Lessons
- Never trust the first connection. Always have retry logic.
- Adapter state is not binary. Handle all six states explicitly.
- Wake before you talk. Devices sleep deeper than you expect.
- Abstract the transport early. We added USB support months later with zero protocol changes.