Flutter BLE Peripheral: Advertising a GATT Service
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
Peripheral → Start Advertising ("A15")
Central → Scan BLE devices
Central → Filter "a15"
Central → Connect
Peripheral → Shows "Central connected"
Central → Read / Write / Subscribe
Peripheral → Responds accordingly