Using Bluetooth Low Energy (BLE) with Flutter

Kürşat Fevzican Şayhan
8 min readMay 27, 2024

--

In this article, we will examine the use of BLE with Flutter. BLE is used in every area where devices can communicate with each other in daily life. It is used in all areas where the IOT field enters our lives such as health, storage, smart home technologies, sports. It is especially preferred due to its low energy consumption.

First we will start with the BLE Server installation, then we will move on to the Client installation.

BLE Server

I used ESP32 and Arduino IDE for BLE Server. Since I don’t have much competence on the hardware side, I thought that development boards like ESP32 would solve my job more easily.

First, let’s add the libraries.

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>

In BLE, unlike Bluetooth Classic, we will do our communications with Service and Characteristic which are connected to those Service structures. For this, let’s define Service and Characteristic UUID and their variables with default values.

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

BLEServer *pServer;
BLEService *pService;
BLECharacteristic *pCharacteristic;

In the setup() function, we first define the name of our BLE device. Then we create our BLE Server and add a Service to this Server and a Characteristic in that Service. Since we will use both read and write features, we turn on both features.

BLEDevice::init("KFS BLE");
pServer = BLEDevice::createServer();
pService = pServer->createService(SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE
);

Since we add the Service and Characteristic features at the end in the BLE server, we will connect to the Service and Characteristic at the end when connecting to the mobile application side in the same way.

We finish the BLE setup by starting the services we created.

pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMinPreferred(0x12);
BLEDevice::startAdvertising();

We define the necessary function in the loop() function for messages from the client.

std::string value = pCharacteristic->getValue();

Let’s combine this with the message sending function. When the message arrives, let’s check that the message is not empty and then return the message to the client as

“Message Received = → message”.

void loop()
{
std::string value = pCharacteristic->getValue();


if(strcmp(value.c_str(), "") != 0)
{
Serial.println(value.c_str());
Serial.println(value.length());

String message = " Message Received = ";
message += value.c_str();
message += "----->";
pCharacteristic->setValue(message.c_str());
pCharacteristic->notify();
}

pCharacteristic->setValue("");
delay(2000);
}

This completes the server side. We will be able to send and receive messages via Bluetooth.

Mobile Application — Client

On the mobile application side, let’s start by adding our permissions first.

In addition to the hardware permission on our Android side in permissions, we add our separate permissions for Android 12 and later for Android 11 and earlier to our Android.manifest file.

Set android:required="true" if bluetooth is necessary -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

<!-- New Bluetooth permissions in Android 12
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- legacy for Android 11 or lower -->
<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"/>

<!-- legacy for Android 9 or lower -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />

For iOS, we add our required BLE permissions to our Info.plist file.

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Need BLE permission</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Need BLE permission</string>

After adding our permissions, we can move on to adding our libraries.

flutter_blue_plus: 

permission_handler:

chat_bubbles:

flutter_blue_plus is our necessary library for the functions we will use for BLE. permisson_handler is the library where we will get permission from the user for bluetooth. chat_bubbles library is an optional library, we will use it to make the data we send and receive look better.

Now we can move to the code side.

Let’s start by asking for permission for bluetooth.

Future getPermissions()async{
try{
await Permission.bluetooth.request();
}catch(e)
{
print(e.toString());
}
}

@override
void initState() {
// TODO: implement initState
super.initState();
getPermissions();
}

We add the flutter_blue_plus library to our code.

import 'package:flutter_blue_plus/flutter_blue_plus.dart';

Bluetooth Status and On/Off Functions

We will use the FlutterBluePlus.adapterState Stream property to learn the Bluetooth state (On/Off) on our device. With the help of a StreamBuilder, we will update the SwitchTile Widget that we will use every time the state changes.

We will use FlutterBluePlus.turnOn() to turn on the bluetooth of the device; FlutterBluePlus.turnOff(); to turn off the bluetooth of the device.

StreamBuilder(
stream: FlutterBluePlus.adapterState,
builder: (context,snapshot){
if(snapshot.data != null)
{
if(snapshot.data == BluetoothAdapterState.on){bluetoothState = true;}else if(snapshot.data == BluetoothAdapterState.off){bluetoothState = false;}
return Container(
height: 30,
child: SwitchListTile(
activeColor: Color(0xFF015164),
activeTrackColor: Color(0xFF0291B5),
inactiveTrackColor: Colors.grey,
inactiveThumbColor: Colors.white,
selectedTileColor: Colors.red,
title: Text(‘Activate Bluetooth',style: TextStyle(fontSize: 14),),
value: bluetoothState,
onChanged: (bool value) {
setState(() {
bluetoothState = !bluetoothState;
if (value) {
FlutterBluePlus.turnOn();
} else {
FlutterBluePlus.turnOff();
}
});
}
),
);
}else{
return Container();
}
}),

Scan Bluetooth Devices

We will create a separate page for scanning devices. We will go to the page, select our device and use Navigator.pop to send the selected device to the previous page and connect to the main page. This is a scenario I have set up, you can design it in your own way.

We will use FlutterBluePlus.startScan to start the scan and FlutterBluePlus.stopScan to end the scan.

To detect that a scan is being performed, we will use FlutterBluePlus.isScanning as a Stream from StreamBuilder and FloatingActionButton.

floatingActionButton: StreamBuilder<bool>(
stream: FlutterBluePlus.isScanning,
initialData: false,
builder: (c, snapshot) {
if (snapshot.data!) {
return FloatingActionButton(
child: const Icon(Icons.stop,color: Colors.red,),
onPressed: () => FlutterBluePlus.stopScan(),
backgroundColor:Color(0xFFEDEDED),
);
} else {
return FloatingActionButton(
child: Icon(Icons.search,color: Colors.blue.shade300,),
backgroundColor:Color(0xFFEDEDED),
onPressed: ()=> FlutterBluePlus.startScan(timeout: const Duration(seconds: 4))
);
}
},
),

When scanning devices we will use 2 scans. The first is connected devices and the second is devices to be connected.

For connected devices we will call the systemDevices function with a stream.

Stream.periodic(const Duration(seconds:10))
.asyncMap((_) => FlutterBluePlus.systemDevices

For the devices to be connected, when we click on the scan function, it will trigger the scanResult stream and we will pull it.

FlutterBluePlus.scanResults

We will use these two lists with a Column in SingleChildScrollView.

We will send the selected device to the previous page with Navigator.pop. While sending, we will specify whether the device is connected or to be connected in the state variable.

class SelectedDevice{
BluetoothDevice? device;
int? state;

SelectedDevice(this.device,this.state);
}

Device Scan Page

SingleChildScrollView(
child: Column(
children: <Widget>[
StreamBuilder<List<BluetoothDevice>>(
stream: Stream.periodic(const Duration(seconds:10))
.asyncMap((_) => FlutterBluePlus.systemDevices),
initialData: const [],
builder: (c, snapshot){
snapshot.data.toString();
return Column(
children: snapshot.data!.map((d) {
return Column(
children: [
ListTile(
title: Text(d.platformName,style: TextStyle(color: Color(0xFFEDEDED)),),
leading: Icon(Icons.devices,color: Color(0xFFEDEDED).withOpacity(0.3),),
trailing: StreamBuilder<BluetoothConnectionState>(
stream: d.connectionState,
initialData: BluetoothConnectionState.disconnected,
builder: (c, snapshot) {
bool con = snapshot.data == BluetoothConnectionState.connected;
return ElevatedButton(
child: Text('Connect',style: TextStyle(color: con?Colors.green:Colors.red),),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
side: BorderSide(color: con?Colors.green:Colors.red),
borderRadius: BorderRadius.all(Radius.circular(8))
)
),
onPressed: () {Navigator.of(context).pop(SelectedDevice(d,1));}
,
);
},
),
),
Divider()
],
);
})
.toList(),
);
},
),
StreamBuilder<List<ScanResult>>(
stream: FlutterBluePlus.scanResults,
initialData: const [],
builder: (c, snapshot) {
List<ScanResult> scanresults = snapshot.data!;
List<ScanResult> templist = [];
scanresults.forEach((element) {
if(element.device.platformName != "")
{
templist.add(element);
}
});

return Container(
height: 700,
child: ListView.builder(itemCount: templist.length,itemBuilder: (context,index)
{
return Column(
children: [
ListTile(
title: Text(templist[index].device.platformName,style: TextStyle(color: Color(0xFFEDEDED)),),
leading: Icon(Icons.devices,color: Color(0xFFEDEDED).withOpacity(0.3),),
trailing: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
side: BorderSide(color: Colors.orange)
)
),
onPressed: () async{
Navigator.of(context).pop(SelectedDevice(templist[index].device,0));
},child: Text("Connect",style: TextStyle(color: Color(0xFFEDEDED)),)),
),
Divider()
],
);
}),
);
},
),
],
),
),
Device Scanning

Device Connection

Once the device is selected, we go back and capture the data we sent from the previous page.

final SelectedDevice? poppedDevice =
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return SelectBluetoothDevice();
},
),
);

If the data is not null, we add the incoming device to the selectedDevice variable.

selecteddevice = poppedDevice.device;

Then we check the state data of the device. We set the state data on the previous page according to whether the device is connected or not. If the state is 1, the device is already connected. If the state is 0, it means it is not connected but ready to connect. According to the state, I will make my ConnectionStatus variable true/false.

if (poppedDevice != null) {
setState(() {
selecteddevice = poppedDevice.device;
print(poppedDevice.state);
if(poppedDevice.state == 1)
{
BluetoothConnectionState ? ev;
selecteddevice!.state.listen((event) {
if(ev == BluetoothConnectionState .connected)
{
setState(() {
ConnectionStatus = true;
});
}else{
ConnectionStatus = false;
}

});

}else if(poppedDevice.state == 0)
{
ConnectionStatus = false;
}
});
}

Now we can move to the button where we will make the connection. We provide connection with the selecteddevice.connect function.

First we check ConnectionStatus. If it is false, we will not connect it because it is already connected.

await selecteddevice!.connect().then((value){


selecteddevice!.connectionState.listen((event) async{
setState(() {
if(event == BluetoothConnectionState.connected)
{
ConnectionStatus = true;
}else{
ConnectionStatus = false;
}
});
if(event == BluetoothConnectionState.disconnected){
await stream_sub!.cancel();
}
});
});

Read Data

On the server side, we wrote the Service and Characteristic properties ourselves and turned on the WRITE, READ properties.

List<BluetoothService> services = await selecteddevice!.discoverServices();

BluetoothService lastservice = services.last;

BluetoothCharacteristic lastCharacterist = lastservice.characteristics.last;

If we want to access a particular Service or Characteristic value, we need to find it by scanning. Since we added it ourselves, it was added last, so we took the last values.

Instead of calling the read function directly, I put it in StreamSubscription so that we can cancel it when we disconnect the BLE device.

StreamSubscription<List<int>>? stream_sub;
stream_sub = lastCharacterist.onValueReceived.listen((value) async{

if(value.isNotEmpty)

{

String s = new String.fromCharCodes(value);

setState(() {

buffer.add(Message(s,0));

});

}

});

I will show incoming messages in the form of a chat, so I will send them under the Message variable to identify incoming and sent messages.

class Message{

String? text;

int? sender;

Message(this.text,this.sender);

}

Write Data

Here, in the same way, we will use last values because we will use the service and characteristic values we wrote ourselves.

String text = controller.text;
List<BluetoothService> services = await selecteddevice!.discoverServices();

BluetoothService lastservice = services.last;

BluetoothCharacteristic lastCharacterist = lastservice.characteristics.last;
List<int> list = utf8.encode(text);

Uint8List bytes = Uint8List.fromList(list);

setState(() {

buffer.add(Message(text,1));

});



await lastCharacterist.setNotifyValue(true);

await lastCharacterist.write(bytes );

Chat

I saved the data I read and wrote to a buffer list. When saving, I also saved whether the data was sent or received. We will create a chat image using the chat_bubbles library that we specified when adding the libraries.

ListView.builder(

itemCount: buffer.length,

itemBuilder: (context,index){

return BubbleSpecialThree(

color: buffer[index].sender==1?Colors.white70:Colors.lightBlueAccent,

text: buffer[index].text!,

isSender: buffer[index].sender==1?true:false,

textStyle: TextStyle(color: Colors.black),

);

}),
Chat

Conclusion

You can develop the ESP32 code according to your own application. For now, I have designed it to return only the incoming data back to us. In the BLE library, we send and receive data in List<Uint8> type. Depending on the diversity of the application, we can process the data using libraries such as the hex library.

Thanks for your help.

--

--

Kürşat Fevzican Şayhan
Kürşat Fevzican Şayhan

Written by Kürşat Fevzican Şayhan

Hi, I am developing mobile and desktop applications. I use Flutter/Dart language in mobile programming and .Net/C# language in desktop programming.

Responses (1)