Flutter Detect BLE Devices
Mohamad's interest is in Programming (Mobile, Web, Database and Machine Learning). He is studying at the Center For Artificial Intelligence Technology (CAIT), Universiti Kebangsaan Malaysia (UKM).
[1] Create Project
flutter create flut_detect_ble_1
cd flut_detect_ble_1
[2] Add packages
flutter pub add flutter_blue_plus permission_handler
flutter_blue_plus is suitable here because it supports BLE Central role, which is what we need for scanning nearby BLE devices.
[3] update AndroidManifest
Add the following entries above <application>:
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<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" />
Example:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<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" />
<application
android:label="flut_detect_ble_1"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Android 12+ uses BLUETOOTH_SCAN and BLUETOOTH_CONNECT; older Android versions still need legacy Bluetooth/location permissions.
[4] Main codes version 1
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const BleDetectApp());
}
class BleDetectApp extends StatelessWidget {
const BleDetectApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BLE Detector',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
),
home: const BleScanPage(),
);
}
}
class BleScanPage extends StatefulWidget {
const BleScanPage({super.key});
@override
State<BleScanPage> createState() => _BleScanPageState();
}
class _BleScanPageState extends State<BleScanPage> {
final List<ScanResult> _results = [];
StreamSubscription<List<ScanResult>>? _scanSub;
StreamSubscription<bool>? _scanStateSub;
bool _isScanning = false;
String _status = 'Ready';
@override
void initState() {
super.initState();
_scanSub = FlutterBluePlus.scanResults.listen((results) {
setState(() {
_results
..clear()
..addAll(results);
});
});
_scanStateSub = FlutterBluePlus.isScanning.listen((state) {
setState(() {
_isScanning = state;
});
});
}
Future<void> _requestPermissions() async {
await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
].request();
}
Future<void> _startScan() async {
await _requestPermissions();
final adapterState = await FlutterBluePlus.adapterState.first;
if (adapterState != BluetoothAdapterState.on) {
setState(() {
_status = 'Bluetooth is OFF. Please enable Bluetooth.';
});
return;
}
setState(() {
_results.clear();
_status = 'Scanning...';
});
try {
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 10),
);
setState(() {
_status = 'Scan completed';
});
} catch (e) {
setState(() {
_status = 'Scan error: $e';
});
}
}
Future<void> _stopScan() async {
await FlutterBluePlus.stopScan();
setState(() {
_status = 'Scan stopped';
});
}
String _deviceName(ScanResult result) {
final platformName = result.device.platformName;
final advName = result.advertisementData.advName;
if (platformName.isNotEmpty) return platformName;
if (advName.isNotEmpty) return advName;
return 'Unknown BLE Device';
}
@override
void dispose() {
_scanSub?.cancel();
_scanStateSub?.cancel();
FlutterBluePlus.stopScan();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('flut_detect_ble_1'),
),
body: Column(
children: [
const SizedBox(height: 12),
Text(
_status,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isScanning ? _stopScan : _startScan,
icon: Icon(_isScanning ? Icons.stop : Icons.search),
label: Text(_isScanning ? 'Stop Scan' : 'Scan BLE Devices'),
),
const Divider(),
Expanded(
child: _results.isEmpty
? const Center(
child: Text('No BLE devices found yet.'),
)
: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final result = _results[index];
final device = result.device;
return ListTile(
leading: const Icon(Icons.bluetooth),
title: Text(_deviceName(result)),
subtitle: Text(device.remoteId.toString()),
trailing: Text('${result.rssi} dBm'),
);
},
),
),
],
),
);
}
}
Outcome:
1. Left side (Before scanning)
This is the initial state of your app:
Status: Ready
Button: Scan BLE Devices
Popup: Permission request
Permission popup meaning
“find, connect to and determine the relative position…”
This refers to:
BLE scanning requires location-related permission
Android uses signal strength (RSSI) → estimate distance
Buttons:
Allow → Required for BLE scan to work
Don’t allow → Scan will fail
2. Right side (After scanning)
Now your app shows:
Status
Scan completed
Meaning:
Scan ran (≈ 10 seconds)
Results collected successfully
3. Device list explained
Each row represents one BLE device
Example:
Unknown BLE Device
01:3A:68:53:00:63 -45 dBm
(A) Device Name
Example:
Unknown BLE Device
Meaning:
Device did NOT broadcast a name
Very common for:
Arduino
ESP32
Sensors
Some phones
Named device example:
AD_401_RAC_056905_WW_5946
Meaning:
This device broadcasts a name
Likely:
Phone
Smart device
Manufacturer-defined BLE device
(B) MAC Address (Device ID)
Example:
E0:85:4D:54:59:47
Meaning:
Unique identifier for that BLE device
Used for:
Connecting
Filtering
Debugging
Think of it as:
Bluetooth = IP address (in networking)
(C) RSSI (Signal Strength)
Example:
-45 dBm
-61 dBm
-96 dBm
RSSI stands for Received Signal Strength Indicator.
It’s a measurement (reported by wireless devices like Wi‑Fi, Bluetooth, Zigbee, etc.) that estimates how strong the received radio signal is at the receiver. Devices use it to help with things like:
choosing a connection quality (e.g., “strong vs weak” signal),
roaming between access points,
signal-strength displays in apps,
estimating proximity (with lots of caveats).
How it’s usually reported:
RSSI is commonly given in dBm (decibels relative to 1 milliwatt), and for many radios more negative means weaker (e.g., −30 dBm is stronger than −80 dBm). Some systems may use different scales, but dBm is common.
Interpretation:
| RSSI | Distance |
|---|---|
| -30 to -50 | Very close |
| -50 to -70 | Near |
| -70 to -90 | Far |
| < -90 | Very far / weak |
examples:
-45 dBm → VERY close device
-61 dBm → nearby
-96 dBm → far / weak signal
What this means in real world
Your phone is detecting:
Nearby phones
Smart devices
Unknown BLE broadcasters
Possibly IoT devices
Even when:
Bluetooth pairing is NOT done
Devices are NOT connected
This is because BLE uses:
Advertising (broadcast mode)
Why so many “Unknown BLE Device”?
Because:
BLE devices don’t always send a name
Many devices only send:
UUID
raw data
Names are optional in BLE
[5] Main code version 2
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const BleDetectApp());
}
class BleDetectApp extends StatelessWidget {
const BleDetectApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BLE Detector (Filter Toggle)',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
),
home: const BleScanPage(),
);
}
}
class BleScanPage extends StatefulWidget {
const BleScanPage({super.key});
@override
State<BleScanPage> createState() => _BleScanPageState();
}
class _BleScanPageState extends State<BleScanPage> {
final List<ScanResult> _results = [];
final TextEditingController _filterController = TextEditingController();
StreamSubscription<List<ScanResult>>? _scanSub;
StreamSubscription<bool>? _scanStateSub;
bool _isScanning = false;
String _status = 'Ready';
bool _useFilter = false;
String _filterText = '';
@override
void initState() {
super.initState();
_scanSub = FlutterBluePlus.scanResults.listen((results) {
final filtered = results.where((r) {
if (!_useFilter || _filterText.isEmpty) return true;
final name = _deviceName(r).toLowerCase();
return name.contains(_filterText.toLowerCase());
}).toList();
setState(() {
_results
..clear()
..addAll(filtered);
});
});
_scanStateSub = FlutterBluePlus.isScanning.listen((state) {
setState(() {
_isScanning = state;
});
});
}
Future<void> _requestPermissions() async {
await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
].request();
}
Future<void> _startScan() async {
await _requestPermissions();
final adapterState = await FlutterBluePlus.adapterState.first;
if (adapterState != BluetoothAdapterState.on) {
setState(() {
_status = 'Bluetooth is OFF';
});
return;
}
setState(() {
_results.clear();
_status = 'Scanning...';
});
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 10),
);
setState(() {
_status = 'Scan completed';
});
}
Future<void> _stopScan() async {
await FlutterBluePlus.stopScan();
setState(() {
_status = 'Scan stopped';
});
}
void _applyFilter() {
setState(() {
_filterText = _filterController.text.trim();
});
}
String _deviceName(ScanResult r) {
final adv = r.advertisementData.advName;
final name = r.device.platformName;
if (adv.isNotEmpty) return adv;
if (name.isNotEmpty) return name;
return 'Unknown BLE Device';
}
Future<void> _connectDevice(BluetoothDevice device) async {
try {
await device.connect(
timeout: const Duration(seconds: 5),
license: License.free,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connected to ${device.remoteId}')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection failed: $e')),
);
}
}
@override
void dispose() {
_scanSub?.cancel();
_scanStateSub?.cancel();
_filterController.dispose();
FlutterBluePlus.stopScan();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('flut_detect_ble_1'),
),
body: Column(
children: [
const SizedBox(height: 10),
Text(_status),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: _isScanning ? _stopScan : _startScan,
icon: Icon(_isScanning ? Icons.stop : Icons.search),
label: Text(_isScanning ? 'Stop Scan' : 'Scan BLE Devices'),
),
const SizedBox(height: 10),
// Toggle filter
SwitchListTile(
title: const Text('Enable Filter'),
value: _useFilter,
onChanged: (val) {
setState(() {
_useFilter = val;
});
},
),
// Filter input
if (_useFilter)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _filterController,
decoration: const InputDecoration(
labelText: 'Filter by name',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _applyFilter,
child: const Text('Apply'),
),
],
),
),
const Divider(),
Expanded(
child: _results.isEmpty
? const Center(child: Text('No devices found'))
: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final r = _results[index];
return ListTile(
leading: const Icon(Icons.bluetooth),
title: Text(_deviceName(r)),
subtitle: Text(r.device.remoteId.toString()),
trailing: Text('${r.rssi} dBm'),
onTap: () => _connectDevice(r.device),
);
},
),
),
],
),
);
}
}
Outcome:
At this stage, connecting to an unknown BLE device is usually less useful because:
It may reject the connection.
It may not expose readable GATT characteristics.
You do not know its Service UUID or Characteristic UUID.
Even if it connects, the data may be proprietary or unreadable.
Some devices only advertise and do not accept connections.
GATT stands for Generic Attribute Profile.
In Bluetooth Low Energy (BLE), GATT defines how data is organized and communicated once you connect:
A BLE device exposes data as attributes arranged in a hierarchy:
Services → contain Characteristics
Characteristics → hold values and can be read, written, and/or notified/indicated
Common actions using GATT
Read a characteristic value (e.g., read current temperature)
Write a characteristic value (e.g., control an LED)
Notify/Indicate characteristics (the server pushes updates to the client)
Roles involved
GATT is used with the BLE roles:
Peripheral (GATT server): hosts the services/characteristics
Central (GATT client): reads/writes/subscribes to those characteristics