Skip to main content

Command Palette

Search for a command to run...

Flutter Detect BLE Devices

Updated
9 min read
M

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:

  1. It may reject the connection.

  2. It may not expose readable GATT characteristics.

  3. You do not know its Service UUID or Characteristic UUID.

  4. Even if it connects, the data may be proprietary or unreadable.

  5. 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

23 views