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.
180Dper 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:
withServicesfiltra 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) owithRemoteIdsper riconnetterti a un dispositivo noto. FlutterBluePlus.scanResultsemette 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
remoteIde usawithRemoteIdsoautoConnectinvece 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.