Flutter BLE: Building a Bluetooth IoT Controller App from Scratch
Bluetooth Low Energy is the right choice for short-range IoT devices — wearables, industrial sensors, home automation, and medical devices all use BLE because it consumes milliwatts instead of watts. Flutter's BLE ecosystem has matured significantly, and flutter_blue_plus is now the production-standard library.
This guide takes you from zero to a working Flutter BLE IoT controller that scans, connects, reads sensor data in real time, and writes commands to your embedded device.
Understanding BLE for IoT Engineers
Before touching Flutter code, you need to understand BLE's data model:
Device (Peripheral)
└── Service (UUID: 180F = Battery, or custom UUID)
├── Characteristic (UUID: read/write/notify)
│ └── Value (bytes)
└── Characteristic (UUID: write-only)
└── Value (bytes: your command)
Your ESP32/STM32 firmware exposes Services (logical groups) containing Characteristics (individual data points). A temperature sensor might expose:
12345678-1234-1234-1234-1234567890ABTEMP (UUID: ...01): read + notify, value is 4-byte float
- Characteristic CTRL (UUID: ...02): write-only, accepts on/off commandSetup: flutter_blue_plus
pubspec.yaml
dependencies:
flutter_blue_plus: ^1.31.0
permission_handler: ^11.0.0
iOS permissions (Info.plist)
NSBluetoothAlwaysUsageDescription
Required to connect to IoT sensors
NSBluetoothPeripheralUsageDescription
Required to connect to IoT sensors
Android permissions (AndroidManifest.xml)
Step 1: Scanning for BLE Devices
import 'package:flutter_blue_plus/flutter_blue_plus.dart';class BLEScanService {
StreamSubscription>? _scanSub;
Stream> get scanResults => FlutterBluePlus.scanResults;
Future startScan({Duration timeout = const Duration(seconds: 10)}) async {
// Request permissions first
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await FlutterBluePlus.startScan(
withServices: [Guid('12345678-1234-1234-1234-1234567890AB')], // Filter by your service UUID
timeout: timeout,
androidUsesFineLocation: false,
);
}
Future stopScan() async {
await FlutterBluePlus.stopScan();
}
}
Filtering by service UUID is critical in production — it prevents your app from showing every BLE device nearby and only surfaces your IoT hardware.
Step 2: Connecting and Discovering Services
class BLEDeviceService {
BluetoothDevice? _device;
BluetoothCharacteristic? _tempCharacteristic;
BluetoothCharacteristic? _ctrlCharacteristic; static const String SERVICE_UUID = '12345678-1234-1234-1234-1234567890AB';
static const String TEMP_UUID = '12345678-1234-1234-1234-1234567890AC';
static const String CTRL_UUID = '12345678-1234-1234-1234-1234567890AD';
Future connect(BluetoothDevice device) async {
_device = device;
await device.connect(
timeout: const Duration(seconds: 15),
autoConnect: false, // true causes connection delays on Android
);
// Monitor connection state
device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected) {
_scheduleReconnect(device);
}
});
await _discoverServices(device);
}
Future _discoverServices(BluetoothDevice device) async {
final services = await device.discoverServices();
for (final service in services) {
if (service.uuid == Guid(SERVICE_UUID)) {
for (final char in service.characteristics) {
if (char.uuid == Guid(TEMP_UUID)) _tempCharacteristic = char;
if (char.uuid == Guid(CTRL_UUID)) _ctrlCharacteristic = char;
}
}
}
}
// Reconnect with exponential backoff
int _reconnectAttempts = 0;
Future _scheduleReconnect(BluetoothDevice device) async {
final delay = Duration(seconds: [2, 5, 10, 30][_reconnectAttempts.clamp(0, 3)]);
await Future.delayed(delay);
_reconnectAttempts++;
try {
await connect(device);
_reconnectAttempts = 0;
} catch (_) {
_scheduleReconnect(device);
}
}
}
Step 3: Real-Time Sensor Data via Notify
BLE notify is the equivalent of WebSocket for Bluetooth — the device pushes data to your app without polling:
Stream streamTemperature() async* {
if (_tempCharacteristic == null) throw StateError('Not connected'); // Enable notifications on the characteristic
await _tempCharacteristic!.setNotifyValue(true);
await for (final value in _tempCharacteristic!.onValueReceived) {
// Parse 4-byte IEEE 754 float (little-endian, as set in ESP32 firmware)
final byteData = ByteData.sublistView(Uint8List.fromList(value));
yield byteData.getFloat32(0, Endian.little);
}
}
On the ESP32 side, your firmware notifies every 500ms:
// ESP32 firmware (simplified)
float temperature = readTemperatureSensor();
uint8_t buf[4];
memcpy(buf, &temperature, 4);
pTempCharacteristic->setValue(buf, 4);
pTempCharacteristic->notify();
Step 4: State Management with Riverpod
// providers.dart
final bleServiceProvider = Provider((ref) => BLEDeviceService());final temperatureProvider = StreamProvider.autoDispose((ref) {
final service = ref.watch(bleServiceProvider);
return service.streamTemperature();
});
// In your widget
class TemperatureWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final temp = ref.watch(temperatureProvider);
return temp.when(
data: (value) => Text(
'${value.toStringAsFixed(1)}°C',
style: const TextStyle(fontSize: 48, color: Colors.white),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Disconnected', style: TextStyle(color: Colors.red)),
);
}
}
Step 5: Writing Commands to the Device
enum DeviceCommand { on, off, reset }Future sendCommand(DeviceCommand cmd) async {
if (_ctrlCharacteristic == null) throw StateError('Not connected');
final byte = switch (cmd) {
DeviceCommand.on => [0x01],
DeviceCommand.off => [0x00],
DeviceCommand.reset => [0xFF],
};
await _ctrlCharacteristic!.write(byte, withoutResponse: false);
// withoutResponse: false = BLE Write With Response (guaranteed delivery)
// withoutResponse: true = BLE Write Without Response (faster, no ACK)
}
Background BLE
On iOS, background BLE requires the bluetooth-central background mode and a CoreBluetooth state restoration identifier. On Android, use a Foreground Service:
// Start foreground service to keep BLE alive in background (Android)
await FlutterBluePlus.setLogLevel(LogLevel.none);// Add to AndroidManifest.xml:
//
Production Checklist
BluetoothAdapterState.off gracefullyBuilding BLE-connected IoT apps is our bread and butter at Code Caracal. [Contact us](/contact) if you need a production BLE IoT controller built.