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:

  1. Scan for devices advertising the Meshtastic service UUID
  2. Connect to the selected peripheral
  3. Discover services and characteristics
  4. Subscribe to notifications on the FromRadio characteristic
  5. Send want_config to trigger the config download
  6. Wait for config_complete before sending any other commands
  7. 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 state
  • resetting — the adapter is restarting (happens after airplane mode toggle)
  • unsupported — hardware does not support BLE
  • unauthorized — user denied Bluetooth permission
  • poweredOff — Bluetooth is disabled
  • poweredOn — 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

  1. Never trust the first connection. Always have retry logic.
  2. Adapter state is not binary. Handle all six states explicitly.
  3. Wake before you talk. Devices sleep deeper than you expect.
  4. Abstract the transport early. We added USB support months later with zero protocol changes.