Perché il BLE è diverso da tutto il resto

Se sei abituato a chiamare API REST, il Bluetooth Low Energy ti costringerà a cambiare mentalità. Non esiste un "server" sempre disponibile: esiste un dispositivo fisico che può essere fuori portata, con la batteria scarica, già connesso a un altro telefono o semplicemente in uno stato inconsistente. Ogni operazione può fallire, e fallirà.

In Flutter il pacchetto di riferimento è flutter_blue_plus, fork mantenuto attivamente dello storico flutter_blue, che supporta Android, iOS, macOS e (parzialmente) Windows/Linux. In questo articolo vediamo come costruire un livello BLE affidabile, dai permessi fino allo streaming di dati da una caratteristica.

Concetti fondamentali (in due minuti)

  • Central / Peripheral: il telefono è tipicamente il central, il dispositivo (sensore, bilancia, braccialetto) è il peripheral.
  • Advertising: il peripheral trasmette pacchetti che il central rileva durante la scansione.
  • GATT: la struttura dati esposta dal peripheral una volta connessi.
  • Service: un gruppo logico di funzionalità, identificato da un UUID (es. 180D per Heart Rate).
  • Characteristic: il singolo "campo" leggibile, scrivibile o notificabile all'interno di un service.
  • Descriptor: metadati di una caratteristica; il più noto è il CCCD, che abilita le notifiche.

Gli UUID standard sono a 16 bit (es. 2A37), quelli custom a 128 bit.

Installazione e configurazione

dependencies:
  flutter_blue_plus: ^1.32.12
  permission_handler: ^11.3.1

Android

Su Android 12+ i permessi Bluetooth sono runtime permission separate dalla posizione. In android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- Solo per Android 11 e precedenti -->
<uses-permission android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    android:maxSdkVersion="30" />

<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

Attenzione: il flag neverForLocation è una dichiarazione formale. Se la tua app deduce la posizione dai beacon rilevati (es. iBeacon), devi rimuoverlo e richiedere ACCESS_FINE_LOCATION anche su Android 12+.

Serve inoltre minSdkVersion 21 (meglio 23) in android/app/build.gradle.

iOS

In ios/Runner/Info.plist:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>L'app usa il Bluetooth per collegarsi al tuo sensore.</string>

Se non compili una descrizione sensata, Apple rifiuta la build in review. Ricorda anche che il BLE non funziona sul simulatore iOS: serve un dispositivo reale.

Verificare adapter e permessi

Prima di scansionare, controlla che l'hardware sia presente e acceso. FlutterBluePlus.adapterState è uno stream: usalo per reagire quando l'utente spegne il Bluetooth mentre l'app è aperta.

import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';

Future<bool> prepareBle() async {
  if (!await FlutterBluePlus.isSupported) return false;

  // Android: richiesta esplicita dei permessi runtime
  final statuses = await [
    Permission.bluetoothScan,
    Permission.bluetoothConnect,
  ].request();

  if (statuses.values.any((s) => !s.isGranted)) return false;

  // Su Android possiamo chiedere all'utente di accendere l'adapter
  if (FlutterBluePlus.adapterStateNow == BluetoothAdapterState.off) {
    try {
      await FlutterBluePlus.turnOn();
    } catch (_) {
      return false; // non supportato su iOS
    }
  }

  final state = await FlutterBluePlus.adapterState
      .where((s) => s == BluetoothAdapterState.on)
      .first
      .timeout(const Duration(seconds: 10));

  return state == BluetoothAdapterState.on;
}

Scansione dei dispositivi

La scansione è costosa in termini di batteria: filtra sempre e imposta un timeout.

final heartRateService = Guid('180D');

Stream<List<ScanResult>> scanDevices() {
  FlutterBluePlus.startScan(
    withServices: [heartRateService], // filtro lato sistema
    timeout: const Duration(seconds: 15),
    androidUsesFineLocation: false,
  );
  return FlutterBluePlus.scanResults;
}

Alcune note importanti:

  • withServices filtra a livello di sistema operativo: molto più efficiente che filtrare in Dart, ma funziona solo se il dispositivo pubblicizza quel service nell'advertising.
  • Puoi usare anche withNames, withKeywords (solo Android) o withRemoteIds per riconnetterti a un dispositivo noto.
  • FlutterBluePlus.scanResults emette la lista completa aggiornata: non serve accumulare i risultati a mano.
  • Ferma sempre la scansione prima di connetterti: await FlutterBluePlus.stopScan();

Un widget minimale:

StreamBuilder<List<ScanResult>>(
  stream: FlutterBluePlus.scanResults,
  initialData: const [],
  builder: (context, snapshot) {
    final results = snapshot.data!
      ..sort((a, b) => b.rssi.compareTo(a.rssi));
    return ListView.builder(
      itemCount: results.length,
      itemBuilder: (context, i) {
        final r = results[i];
        return ListTile(
          title: Text(r.device.platformName.isEmpty
              ? 'Sconosciuto'
              : r.device.platformName),
          subtitle: Text(r.device.remoteId.str),
          trailing: Text('${r.rssi} dBm'),
          onTap: () => _connect(r.device),
        );
      },
    );
  },
)

Il remoteId è il MAC address su Android e un UUID generato dal sistema su iOS: non è portabile tra piattaforme, ma è stabile sullo stesso dispositivo e va salvato per le riconnessioni rapide.

Connessione e ciclo di vita

Future<void> _connect(BluetoothDevice device) async {
  await FlutterBluePlus.stopScan();

  // Ascolta la connessione PRIMA di connetterti
  final sub = device.connectionState.listen((state) {
    debugPrint('Stato: $state');
    if (state == BluetoothConnectionState.disconnected) {
      debugPrint('Motivo: ${device.disconnectReason?.description}');
    }
  });
  device.cancelWhenDisconnected(sub, delayed: true, next: true);

  await device.connect(timeout: const Duration(seconds: 15));

  // Su Android un MTU maggiore riduce drasticamente i round trip
  if (Theme.of(context).platform == TargetPlatform.android) {
    await device.requestMtu(247);
  }
}

cancelWhenDisconnected è una comodità di flutter_blue_plus: annulla automaticamente la subscription quando il dispositivo si disconnette, evitando memory leak e listener zombie.

Per riconnetterti in automatico quando il dispositivo torna in portata, su Android puoi usare:

await device.connect(autoConnect: true, mtu: null);
await device.connectionState
    .where((s) => s == BluetoothConnectionState.connected)
    .first;

Con autoConnect: true la chiamata ritorna subito e il sistema tenta la connessione in background; per questo il parametro mtu deve essere null.

Scoprire servizi e caratteristiche

Future<BluetoothCharacteristic?> findCharacteristic(
  BluetoothDevice device,
  Guid serviceUuid,
  Guid characteristicUuid,
) async {
  final services = await device.discoverServices();
  for (final service in services) {
    if (service.serviceUuid != serviceUuid) continue;
    for (final c in service.characteristics) {
      if (c.characteristicUuid == characteristicUuid) return c;
    }
  }
  return null;
}

discoverServices() va chiamata dopo la connessione e prima di qualsiasi operazione di lettura/scrittura. Su Android l'operazione può richiedere qualche secondo.

Leggere, scrivere e ricevere notifiche

// Lettura one-shot
final List<int> value = await characteristic.read();

// Scrittura con risposta (affidabile ma più lenta)
await characteristic.write([0x01, 0x0A], withoutResponse: false);

// Notifiche: il pattern più usato per i sensori
final sub = characteristic.onValueReceived.listen((data) {
  final bpm = _parseHeartRate(data);
  debugPrint('Battito: $bpm');
});
device.cancelWhenDisconnected(sub);

await characteristic.setNotifyValue(true);

L'ordine conta: iscriviti a onValueReceived prima di chiamare setNotifyValue(true), altrimenti rischi di perdere i primi pacchetti.

Il parsing dei byte è a tuo carico. Usa ByteData per gestire endianness e tipi:

int _parseHeartRate(List<int> data) {
  if (data.isEmpty) return 0;
  final bytes = Uint8List.fromList(data);
  final flags = bytes[0];
  final isUint16 = (flags & 0x01) != 0;
  final bd = ByteData.sublistView(bytes);
  return isUint16 ? bd.getUint16(1, Endian.little) : bytes[1];
}

Incapsulare tutto in un repository

Spargere chiamate a FlutterBluePlus nei widget è la ricetta per il caos. Meglio un repository che esponga uno stream tipizzato e nasconda i dettagli del protocollo.

class HeartRateRepository {
  static final _service = Guid('180D');
  static final _measurement = Guid('2A37');

  BluetoothDevice? _device;
  StreamSubscription<List<int>>? _valueSub;
  final _controller = StreamController<int>.broadcast();

  Stream<int> get heartRate => _controller.stream;

  Future<void> connect(BluetoothDevice device) async {
    _device = device;
    await device.connect(timeout: const Duration(seconds: 15));

    final services = await device.discoverServices();
    final characteristic = services
        .firstWhere((s) => s.serviceUuid == _service)
        .characteristics
        .firstWhere((c) => c.characteristicUuid == _measurement);

    _valueSub = characteristic.onValueReceived
        .map(_parseHeartRate)
        .listen(_controller.add, onError: _controller.addError);
    device.cancelWhenDisconnected(_valueSub!);

    await characteristic.setNotifyValue(true);
  }

  Future<void> disconnect() async {
    await _valueSub?.cancel();
    await _device?.disconnect();
    _device = null;
  }

  void dispose() {
    _controller.close();
  }
}

Da qui il passo verso Riverpod, BLoC o qualsiasi altra soluzione di state management è breve: il repository espone solo Stream<int> e due metodi.

Gestire gli errori come si deve

Ogni operazione BLE può lanciare una FlutterBluePlusException, che contiene il codice di errore nativo:

try {
  await device.connect(timeout: const Duration(seconds: 15));
} on FlutterBluePlusException catch (e) {
  // e.code: 133 su Android è il famigerato GATT_ERROR generico
  if (e.code == 133) {
    await Future.delayed(const Duration(seconds: 2));
    await device.connect(); // un retry risolve nella maggior parte dei casi
  } else {
    rethrow;
  }
} on TimeoutException {
  // dispositivo fuori portata
}

Strategie che salvano ore di debugging:

  • Serializza le operazioni: non lanciare read/write in parallelo sullo stesso dispositivo. flutter_blue_plus mette già in coda le richieste, ma il tuo codice deve rispettare l'ordine logico.
  • Retry con backoff sulle connessioni: il codice 133 di Android è quasi sempre transitorio.
  • Timeout ovunque: nessuna Future BLE deve poter restare pendente all'infinito.
  • Un solo punto di verità sullo stato della connessione, altrimenti la UI mostrerà dati incoerenti.

Log e strumenti di debug

FlutterBluePlus.setLogLevel(LogLevel.verbose, color: true);

Affianca sempre un'app di riferimento come nRF Connect (Nordic Semiconductor) per verificare se il problema è nel tuo codice o nel firmware del dispositivo: se il service non compare nemmeno lì, non è colpa di Flutter.

Consumo energetico e background

La scansione continua è il modo più rapido per prosciugare la batteria. Alcune regole pratiche:

  • Scansiona per finestre brevi (10-15 secondi), poi fermati.
  • Dopo il primo accoppiamento salva il remoteId e usa withRemoteIds o autoConnect invece di riscansionare tutto.
  • Riduci la frequenza delle notifiche lato firmware quando possibile: è più efficiente di filtrare in Dart.
  • Per il background su iOS servono i Background Modes (bluetooth-central) e vincoli precisi: il sistema può sospendere l'app in qualsiasi momento. Su Android valuta un Foreground Service.

Conclusioni

Il BLE in Flutter è pienamente fattibile, ma richiede disciplina: permessi corretti per ogni versione di Android, stream sottoscritti nell'ordine giusto, timeout e retry su ogni operazione, e un repository che isoli la complessità dal resto dell'app. Parti da un caso d'uso semplice (una caratteristica in notifica), verifica il comportamento su dispositivi reali di fascia bassa e solo dopo aggiungi riconnessione automatica e gestione del background.