Skip to main content

Command Palette

Search for a command to run...

Flutter BLE Peripheral: Advertising a GATT Service

Updated
6 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).

Introduction

Bluetooth Low Energy (BLE) enables mobile applications to communicate with nearby devices efficiently. In Flutter, most examples focus on scanning and connecting to BLE devices (Central role). However, modern smartphones can also act as BLE Peripherals, simulating IoT devices such as sensors, smart switches, or embedded systems.

This article demonstrates how to build a Flutter BLE Peripheral application that:

  • Advertises itself as a BLE device

  • Exposes a GATT service

  • Supports read, write, and notification operations

  • Communicates with another Flutter app acting as a BLE Central

BLE Roles Overview

BLE communication involves two main roles:

  • Role Description Central Scans and connects to devices (e.g., mobile app)

  • Peripheral Advertises and provides data/services (e.g., IoT device)

In this setup:

flut_detect_ble_1 → BLE Central (scanner/controller) flut_ble_peripheral_1 → BLE Peripheral (device simulator)

Understanding the BLE Workflow

We simplify BLE interaction into two conceptual layers:

Layer 1: Discovery & Connection

Scan → Filter → Connect
  • Peripheral advertises itself

  • Central scans and finds the device

  • User selects and connects

Layer 2: Data Communication (GATT)

Discover → Read → Write → Notify
  • Central interacts with the peripheral

  • Data is exchanged using GATT (Generic Attribute Profile)

What is GATT?

GATT (Generic Attribute Profile) defines how BLE devices exchange data.

Structure:

Device
 └── Service
      └── Characteristic
            └── Value
  • Service → group of related functions

  • Characteristic → individual data point

  • Value → actual data


[1] Create Project

flutter create flut_ble_peripheral_1
cd flut_ble_peripheral_1

[2] Add packages

name: flut_ble_peripheral_1
description: BLE peripheral
publish_to: "none"

environment:
  sdk: ">=3.3.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter

  ble_peripheral: ^2.4.0
  permission_handler: ^11.3.1

flutter:
  uses-material-design: true

[3] update AndroidManifest

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.flut_ble_peripheral_1">

    <!-- ===== Permissions for Android <= 11 ===== -->
    <uses-permission
        android:name="android.permission.BLUETOOTH"
        android:maxSdkVersion="30" />
    <uses-permission
        android:name="android.permission.BLUETOOTH_ADMIN"
        android:maxSdkVersion="30" />

    <!-- ===== Permissions for Android 12+ ===== -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

    <!-- ===== Location (only needed for older Android when scanning) ===== -->
    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION"
        android:maxSdkVersion="30" />

    <!-- ===== Ensure device supports BLE ===== -->
    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

    <application
        android:label="flut_ble_peripheral_1"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="io.flutter.embedding.android.NormalTheme"
                android:resource="@style/NormalTheme" />
        </activity>

        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />

    </application>

</manifest>

[4] Main codes

import 'dart:convert';
import 'dart:typed_data';

import 'package:ble_peripheral/ble_peripheral.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter BLE Peripheral',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.blue,
      ),
      home: const BlePeripheralPage(),
    );
  }
}

class BlePeripheralPage extends StatefulWidget {
  const BlePeripheralPage({super.key});

  @override
  State<BlePeripheralPage> createState() => _BlePeripheralPageState();
}

class _BlePeripheralPageState extends State<BlePeripheralPage> {
  static const String serviceUuid =
      "12345678-1234-1234-1234-1234567890AB";

  static const String characteristicUuid =
      "12345678-1234-1234-1234-1234567890AC";

  bool _initialized = false;
  bool _advertising = false;

  String _status = "Idle";
  String _lastCentral = "None";
  String _lastWrittenValue = "";
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _setupCallbacks();
  }

  void _setupCallbacks() {
    // Advertising status
    BlePeripheral.setAdvertisingStatusUpdateCallback(
          (bool advertising, String? error) {
        if (!mounted) return;
        setState(() {
          _advertising = advertising;
          _status = error == null
              ? (advertising
              ? "Advertising started"
              : "Advertising stopped")
              : "Advertising error: $error";
        });
      },
    );

    // Connection state
    BlePeripheral.setConnectionStateChangeCallback(
          (String deviceId, bool connected) {
        if (!mounted) return;
        setState(() {
          _lastCentral = deviceId;
          _status = connected
              ? "Central connected: $deviceId"
              : "Central disconnected: $deviceId";
        });
      },
    );

    // READ request
    BlePeripheral.setReadRequestCallback(
          (
          String characteristicId,
          String deviceId,
          int offset,
          Uint8List? value,
          ) {
        final bytes = Uint8List.fromList(
          utf8.encode("flut_ble_peripheral_1 value $_counter"),
        );

        if (mounted) {
          setState(() {
            _lastCentral = deviceId;
            _status = "Read request from $deviceId";
          });
        }

        return ReadRequestResult(
          value: bytes,
          offset: offset,
        );
      },
    );

    // WRITE request
    BlePeripheral.setWriteRequestCallback(
          (
          String characteristicId,
          String deviceId,
          int offset,
          Uint8List? value,
          ) {
        final text =
        value == null ? "" : utf8.decode(value, allowMalformed: true);

        if (mounted) {
          setState(() {
            _lastCentral = deviceId;
            _lastWrittenValue = text;
            _status = "Write from \(deviceId: \)text";
          });
        }

        return WriteRequestResult(
          value: value,
          offset: offset,
        );
      },
    );

    // Subscribe / Unsubscribe
    BlePeripheral.setCharacteristicSubscriptionChangeCallback(
          (
          String characteristicId,
          String deviceId,
          bool subscribed,
          String? name,
          ) {
        if (!mounted) return;
        setState(() {
          _lastCentral = name == null ? deviceId : "\(name (\)deviceId)";
          _status = subscribed
              ? "Subscribed to notifications"
              : "Unsubscribed from notifications";
        });
      },
    );
  }

  Future<bool> _requestPermissions() async {
    final statuses = await [
      Permission.bluetoothConnect,
      Permission.bluetoothAdvertise,
      Permission.bluetoothScan,
    ].request();

    return statuses.values.every((status) => status.isGranted);
  }

  Future<void> _initializeBle() async {
    final granted = await _requestPermissions();

    if (!granted) {
      if (!mounted) return;
      setState(() {
        _status = "Bluetooth permissions not granted";
      });
      return;
    }

    await BlePeripheral.initialize();
    await BlePeripheral.clearServices();

    await BlePeripheral.addService(
      BleService(
        uuid: serviceUuid,
        primary: true,
        characteristics: [
          BleCharacteristic(
            uuid: characteristicUuid,
            properties: [
              CharacteristicProperties.read.index,
              CharacteristicProperties.write.index,
              CharacteristicProperties.notify.index,
            ],
            permissions: [
              AttributePermissions.readable.index,
              AttributePermissions.writeable.index,
            ],
            value: Uint8List.fromList(
              utf8.encode("flut_ble_peripheral_1 ready"),
            ),
          ),
        ],
      ),
    );

    if (!mounted) return;
    setState(() {
      _initialized = true;
      _status = "BLE initialized";
    });
  }

  Future<void> _startAdvertising() async {
    if (!_initialized) {
      await _initializeBle();
      if (!_initialized) return;
    }

    await BlePeripheral.startAdvertising(
      services: const [],
      localName: "A15",
    );
  }

  Future<void> _stopAdvertising() async {
    await BlePeripheral.stopAdvertising();
  }

  Future<void> _notifyValue() async {
    _counter++;

    final bytes = Uint8List.fromList(
      utf8.encode("Ping $_counter"),
    );

    await BlePeripheral.updateCharacteristic(
      characteristicId: characteristicUuid,
      value: bytes,
    );

    if (!mounted) return;
    setState(() {
      _status = "Notification sent: Ping $_counter";
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("flut_ble_peripheral_1"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text("Initialized: $_initialized"),
            const SizedBox(height: 8),
            Text("Advertising: $_advertising"),
            const SizedBox(height: 8),
            const Text("Advertised name: A15"),
            const SizedBox(height: 8),
            Text("Service UUID: $serviceUuid"),
            const SizedBox(height: 8),
            Text("Characteristic UUID: $characteristicUuid"),
            const SizedBox(height: 8),
            Text("Last central: $_lastCentral"),
            const SizedBox(height: 8),
            Text("Last written value: $_lastWrittenValue"),
            const SizedBox(height: 8),
            Text("Status: $_status"),
            const SizedBox(height: 24),

            FilledButton(
              onPressed: _initializeBle,
              child: const Text("Initialize BLE"),
            ),

            const SizedBox(height: 12),

            FilledButton(
              onPressed: _advertising ? null : _startAdvertising,
              child: const Text("Start Advertising"),
            ),

            const SizedBox(height: 12),

            OutlinedButton(
              onPressed: _advertising ? _stopAdvertising : null,
              child: const Text("Stop Advertising"),
            ),

            const SizedBox(height: 12),

            OutlinedButton(
              onPressed: _advertising ? _notifyValue : null,
              child: const Text("Send Notification"),
            ),
          ],
        ),
      ),
    );
  }
}

Outcome:

Full Interaction Flow

  1. Peripheral → Start Advertising ("A15")

  2. Central → Scan BLE devices

  3. Central → Filter "a15"

  4. Central → Connect

  5. Peripheral → Shows "Central connected"

  6. Central → Read / Write / Subscribe

  7. Peripheral → Responds accordingly

21 views