Note: This deep dive focuses on the essential concepts and implementations, trimming excessive code while preserving educational value.
Introduction
Bluetooth Low Energy (BLE) has become an essential technology for connecting mobile apps to a vast ecosystem of smart devices, from fitness trackers and health monitors to IoT sensors and smart home equipment. While Flutter excels at cross-platform UI development, it doesn’t provide direct access to native BLE capabilities. This is where platform channels come in.
Flutter’s platform channels act as a bridge between your Dart code and the native platform, allowing you to leverage the full power of each platform’s BLE APIs: CoreBluetooth on iOS and the Android Bluetooth API on Android.
BLE Fundamentals
Key Concept: Understanding the BLE architecture is crucial before implementing it in Flutter. BLE follows a hierarchical structure of Services, Characteristics, and Descriptors.
What is BLE?
Bluetooth Low Energy (BLE) is a wireless communication technology designed for short-range, low-power applications. Unlike Classic Bluetooth, BLE:
- Consumes significantly less power
- Is optimized for sending small amounts of data periodically
- Supports a “connectionless” broadcast mode (Advertising)
- Uses a different protocol stack and API
BLE Terminology
- Central: The device that initiates connections (usually your phone)
- Peripheral: The device that accepts connections (e.g., fitness trackers, sensors)
- GATT (Generic Attribute Profile): The foundation for BLE data organization
- Service: A collection of related data and behaviors (e.g., “Battery Service”)
- Characteristic: A specific piece of data within a service (e.g., “Battery Level”)
- Descriptor: Metadata about a characteristic (e.g., units, permissions)
- UUID: Unique identifiers for services and characteristics
The GATT Hierarchy
The following diagram illustrates the hierarchical structure of a typical BLE device:
BLE Communication Flow
- Advertising: Peripherals broadcast their presence and basic information
- Scanning: Central devices discover peripherals by scanning for advertisements
- Connecting: Central establishes a connection with a peripheral
- Service Discovery: Central queries peripheral for available services
- Characteristic Operations: Reading, writing, or subscribing to notifications
- Disconnection: Either device terminates the connection
Common BLE Operations
The diagram below illustrates the typical flow of BLE operations between a central device (like a smartphone) and a peripheral device:
- Read: Get the current value of a characteristic
- Write: Send data to a characteristic (with or without response)
- Notify/Indicate: Subscribe to updates when a characteristic’s value changes
- RSSI: Measure signal strength to estimate distance
Characteristics Properties
Each characteristic has properties that define what operations are supported:
Property | Description |
---|---|
Read | Can be read by the central device |
Write | Can be written by the central device (with response) |
Write Without Response | Can be written without waiting for acknowledgment |
Notify | Can notify the central when its value changes (unacknowledged) |
Indicate | Like notify, but with acknowledgment |
Broadcast | Value can be broadcast in advertisements |
BLE in Mobile Development
On mobile platforms, BLE implementations differ:
- iOS (CoreBluetooth): Uses delegate-based patterns and operation queues
- Android (Bluetooth API): Uses callback patterns and requires runtime permissions
Understanding these platform differences is crucial when implementing BLE in Flutter, as you’ll need to bridge both APIs through platform channels.
Understanding Platform Channels for BLE
Key Concept: Platform channels are the communication bridge that allows Flutter code to interact with native platform capabilities like Bluetooth.
Platform channels in Flutter provide a messaging system between Flutter (Dart) and platform-specific code (Swift/Objective-C for iOS, Kotlin/Java for Android).
Types of Platform Channels for BLE
- MethodChannel: For one-off operations (scanning, connecting, reading/writing)
- EventChannel: For continuous data streams (scan results, notifications)
Communication Flow
The diagram below illustrates how data flows between your Flutter code and BLE devices through platform channels:
This architecture allows your Flutter app to maintain a consistent API while leveraging the full capabilities of each platform’s native BLE implementation.
Flutter Side: Core Implementation
Key Concept: Create a clean abstraction layer in your Flutter code to hide the complexity of platform channels from your UI code.
Project Structure
A well-organized project structure separates concerns and creates clear abstractions:
lib/
ble/
ble_device.dart # BLE device model
ble_manager.dart # Main BLE API (used by UI code)
platform/
method_channel_ble.dart # Platform channel implementation (hidden from UI)
Defining
// ble_device.dart
class BleDevice {
final String id;
final String name;
final int rssi;
final Map<String, dynamic> manufacturerData;
BleDevice({
required this.id,
required this.name,
required this.rssi,
this.manufacturerData = const {},
});
factory BleDevice.fromMap(Map<String, dynamic> map) {
return BleDevice(
id: map['id'],
name: map['name'] ?? 'Unknown',
rssi: map['rssi'] ?? 0,
manufacturerData: map['manufacturerData'] ?? {},
);
}
}
Setting Up Platform Channels
Key Concept: Each BLE operation maps to a method call, while continuous data (like scan results) is handled through event streams.
// method_channel_ble.dart
import 'dart:async';
import 'package:flutter/services.dart';
import '../ble_device.dart';
class MethodChannelBle {
// Method channel for one-off operations
static const MethodChannel _methodChannel =
MethodChannel('com.example.flutter_ble/method');
// Event channel for scan results
static const EventChannel _scanResultsChannel =
EventChannel('com.example.flutter_ble/scan_results');
// BLE methods
Future<void> startScan() async {
try {
await _methodChannel.invokeMethod('startScan');
} catch (e) {
throw 'Failed to start scan: $e';
}
}
Future<void> stopScan() async {
try {
await _methodChannel.invokeMethod('stopScan');
} catch (e) {
throw 'Failed to stop scan: $e';
}
}
Future<bool> connect(String deviceId) async {
try {
return await _methodChannel.invokeMethod('connect', {'deviceId': deviceId});
} catch (e) {
throw 'Failed to connect: $e';
}
}
// Get streams
Stream<BleDevice> get scanResults =>
_scanResultsChannel.receiveBroadcastStream()
.map((dynamic data) => BleDevice.fromMap(Map<String, dynamic>.from(data)));
}
User-Friendly BLE Manager
// ble_manager.dart
import 'dart:async';
import 'ble_device.dart';
import 'platform/method_channel_ble.dart';
enum ConnectionState {
disconnected,
connecting,
connected,
disconnecting
}
class BleManager {
static final BleManager _instance = BleManager._internal();
factory BleManager() => _instance;
final MethodChannelBle _methodChannelBle = MethodChannelBle();
final Map<String, BleDevice> _discoveredDevices = {};
final StreamController<List<BleDevice>> _devicesController =
StreamController.broadcast();
BleManager._internal() {
// Listen to scan results
_methodChannelBle.scanResults.listen((device) {
_discoveredDevices[device.id] = device;
_devicesController.add(_discoveredDevices.values.toList());
});
}
// Scanning methods
Future<void> startScan() async {
_discoveredDevices.clear();
_devicesController.add([]);
await _methodChannelBle.startScan();
}
Future<void> stopScan() async {
await _methodChannelBle.stopScan();
}
// Connection methods
Future<bool> connect(String deviceId) async {
return await _methodChannelBle.connect(deviceId);
}
// Streams
Stream<List<BleDevice>> get devices => _devicesController.stream;
void dispose() {
_devicesController.close();
}
}
iOS Implementation (CoreBluetooth)
Key Concept: On iOS, the CoreBluetooth framework requires delegate patterns to handle BLE events. We convert these delegate callbacks into platform channel messages.
Instead of showing the entire implementation, here’s a focused example of handling scan results:
// In FlutterBlePlugin.swift
// Set up channels
public static func register(with registrar: FlutterPluginRegistrar) {
// Set up method channel
let channel = FlutterMethodChannel(name: "com.example.flutter_ble/method", binaryMessenger: registrar.messenger())
// Set up event channels
let scanResultsChannel = FlutterEventChannel(name: "com.example.flutter_ble/scan_results",
binaryMessenger: registrar.messenger())
let scanResultsStreamHandler = ScanResultsStreamHandler()
scanResultsChannel.setStreamHandler(scanResultsStreamHandler)
// Create and register plugin instance
let instance = FlutterBlePlugin(methodChannel: channel,
scanResultsStreamHandler: scanResultsStreamHandler)
registrar.addMethodCallDelegate(instance, channel: channel)
}
// Handle method calls
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "startScan":
startScan(result: result)
case "stopScan":
stopScan(result: result)
case "connect":
guard let args = call.arguments as? [String: Any],
let deviceId = args["deviceId"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid device ID", details: nil))
return
}
connect(deviceId: deviceId, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// CoreBluetooth delegate method for scanning
public func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String : Any],
rssi RSSI: NSNumber) {
let deviceId = peripheral.identifier.uuidString
peripherals[deviceId] = peripheral
let device: [String: Any] = [
"id": deviceId,
"name": peripheral.name ?? "Unknown",
"rssi": RSSI.intValue,
"manufacturerData": advertisementData[CBAdvertisementDataManufacturerDataKey] as? [String: Any] ?? [:]
]
scanResultsStreamHandler?.sendScanResult(device)
}
Android Implementation
Key Concept: The Android Bluetooth API requires runtime permissions and uses callback patterns for asynchronous operations. Android 12+ (API 31+) introduced new bluetooth-specific permissions.
Here’s a focused example of the Android implementation for scanning:
// In FlutterBlePlugin.kt
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startScan" -> startScan(result)
"stopScan" -> stopScan(result)
"connect" -> {
val deviceId = call.argument<String>("deviceId")
if (deviceId != null) {
connect(deviceId, result)
} else {
result.error("INVALID_ARGUMENT", "Device ID is required", null)
}
}
else -> result.notImplemented()
}
}
private fun startScan(result: Result) {
if (bluetoothAdapter == null || !bluetoothAdapter!!.isEnabled) {
result.error("BLUETOOTH_DISABLED", "Bluetooth is not enabled", null)
return
}
// Check for required permissions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ActivityCompat.checkSelfPermission(context,
Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
result.error("PERMISSION_DENIED", "BLUETOOTH_SCAN permission not granted", null)
return
}
}
devices.clear()
// Start scanning
bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback)
result.success(null)
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val deviceId = device.address
// Store the device for later use
devices[deviceId] = device
val deviceMap = HashMap<String, Any>()
deviceMap["id"] = deviceId
deviceMap["name"] = device.name ?: "Unknown"
deviceMap["rssi"] = result.rssi
scanResultsHandler?.sendScanResult(deviceMap)
}
}
Practical Example: A Simple BLE Scanner
Key Concept: A BLE scanner is an excellent first project for learning BLE integration, as it covers scanning, result handling, and UI integration.
Let’s implement a basic BLE scanner UI that demonstrates the concepts we’ve covered:
class BleScanner extends StatefulWidget {
@override
_BleScannerState createState() => _BleScannerState();
}
class _BleScannerState extends State<BleScanner> {
final BleManager _bleManager = BleManager();
bool _isScanning = false;
void _startScan() async {
setState(() {
_isScanning = true;
});
try {
await _bleManager.startScan();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to start scan: $e')),
);
}
// Auto-stop scan after 10 seconds
Future.delayed(Duration(seconds: 10), () {
if (mounted && _isScanning) {
_stopScan();
}
});
}
void _stopScan() async {
try {
await _bleManager.stopScan();
} finally {
setState(() {
_isScanning = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('BLE Scanner'),
),
body: StreamBuilder<List<BleDevice>>(
stream: _bleManager.devices,
initialData: [],
builder: (context, snapshot) {
final devices = snapshot.data ?? [];
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final device = devices[index];
return ListTile(
title: Text(device.name),
subtitle: Text('RSSI: ${device.rssi} | ID: ${device.id.substring(0, 8)}...'),
trailing: Icon(Icons.bluetooth),
onTap: () => _connectToDevice(device),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _isScanning ? _stopScan : _startScan,
tooltip: _isScanning ? 'Stop Scan' : 'Start Scan',
child: Icon(_isScanning ? Icons.stop : Icons.search),
),
);
}
// This would be implemented to handle device connection
void _connectToDevice(BleDevice device) async {
try {
final connected = await _bleManager.connect(device.id);
if (connected) {
// Navigate to device details page
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection failed: $e')),
);
}
}
}
Working with Service and Characteristic Data
Once connected to a device, you might want to display and interact with its services and characteristics. Here’s a simple service explorer UI:
class DeviceDetailsScreen extends StatefulWidget {
final BleDevice device;
DeviceDetailsScreen({required this.device});
@override
_DeviceDetailsScreenState createState() => _DeviceDetailsScreenState();
}
class _DeviceDetailsScreenState extends State<DeviceDetailsScreen> {
final BleManager _bleManager = BleManager();
List<BluetoothService> _services = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_discoverServices();
}
Future<void> _discoverServices() async {
try {
setState(() {
_isLoading = true;
});
final services = await _bleManager.discoverServices();
setState(() {
_services = services;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error discovering services: $e')),
);
}
}
@override
void dispose() {
_bleManager.disconnect();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.device.name),
),
body: _isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: _services.length,
itemBuilder: (context, index) {
final service = _services[index];
return ExpansionTile(
title: Text('Service: ${service.uuid.toString().substring(0, 8)}...'),
children: service.characteristics.map((characteristic) {
return ListTile(
title: Text('Characteristic: ${characteristic.uuid.toString().substring(0, 8)}...'),
subtitle: Text('Properties: ${characteristic.properties.join(", ")}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (characteristic.properties.contains('read'))
IconButton(
icon: Icon(Icons.refresh),
onPressed: () => _readCharacteristic(characteristic),
),
if (characteristic.properties.contains('notify'))
IconButton(
icon: Icon(Icons.notifications),
onPressed: () => _toggleNotification(characteristic),
),
],
),
);
}).toList(),
);
},
),
);
}
// Reading a characteristic value
Future<void> _readCharacteristic(BleCharacteristic characteristic) async {
try {
final value = await _bleManager.readCharacteristic(
characteristic.serviceUuid,
characteristic.uuid,
);
// Display the value (this will depend on the data format)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Value: ${value.toString()}')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error reading characteristic: $e')),
);
}
}
// Toggle notifications for a characteristic
Future<void> _toggleNotification(BleCharacteristic characteristic) async {
try {
// This would need to be expanded to track current notification state
await _bleManager.setNotification(
characteristic.serviceUuid,
characteristic.uuid,
true, // enable notifications
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Notifications enabled')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error enabling notifications: $e')),
);
}
}
}
Advanced BLE Concepts
Key Concept: In real-world applications, you’ll need to handle more complex aspects of BLE like MTU negotiation, reconnection strategies, and parsing specialized data formats.
MTU Negotiation
MTU (Maximum Transmission Unit) defines how much data can be transferred in a single BLE packet. By default, BLE uses 23 bytes, but it can be negotiated up to 512 bytes (platform-dependent).
Future<int> requestMtu(String deviceId, int mtu) async {
try {
return await _methodChannel.invokeMethod('requestMtu', {
'deviceId': deviceId,
'mtu': mtu
});
} catch (e) {
throw 'Failed to request MTU: $e';
}
}
Handling GATT Data Types
BLE characteristics often use compact data formats that need parsing. For example, parsing a heart rate measurement:
List<int> parseHeartRate(List<int> data) {
final results = <int>[];
// Check format flag (first bit of first byte)
final isFormat16Bit = data[0] & 0x01 == 0x01;
if (isFormat16Bit && data.length >= 3) {
// If format is 16-bit, heart rate is at index 1-2
final heartRate = (data[2] << 8) + data[1];
results.add(heartRate);
} else if (data.length >= 2) {
// If format is 8-bit, heart rate is at index 1
results.add(data[1]);
}
// Parse additional data if available
// ...
return results;
}
Auto-Reconnection Strategy
In real-world applications, BLE connections can drop unexpectedly. Implementing a reconnection strategy is crucial:
class BleReconnectionManager {
final BleManager _bleManager;
final Duration _reconnectInterval;
final int _maxRetries;
String? _deviceId;
Timer? _reconnectTimer;
int _retryCount = 0;
bool _isReconnecting = false;
BleReconnectionManager(
this._bleManager, {
Duration reconnectInterval = const Duration(seconds: 3),
int maxRetries = 5,
}) : _reconnectInterval = reconnectInterval,
_maxRetries = maxRetries {
_setupConnectionListener();
}
void _setupConnectionListener() {
_bleManager.connectionStateStream.listen((state) {
if (state == ConnectionState.disconnected && _deviceId != null) {
_startReconnection();
}
});
}
Future<void> _startReconnection() async {
if (_isReconnecting || _deviceId == null) return;
_isReconnecting = true;
_retryCount = 0;
_reconnectTimer = Timer.periodic(_reconnectInterval, (timer) async {
if (_retryCount >= _maxRetries) {
timer.cancel();
_isReconnecting = false;
return;
}
_retryCount++;
try {
final connected = await _bleManager.connect(_deviceId!);
if (connected) {
timer.cancel();
_isReconnecting = false;
}
} catch (e) {
// Failed to reconnect, will try again
}
});
}
void registerDevice(String deviceId) {
_deviceId = deviceId;
}
void cancelReconnection() {
_reconnectTimer?.cancel();
_isReconnecting = false;
}
void dispose() {
_reconnectTimer?.cancel();
}
}
Best Practices
Key Concept: BLE operations are inherently unreliable due to their wireless nature. Always implement robust error handling, timeout mechanisms, and reconnection strategies.
1. Error Handling
Future<bool> connectWithRetry(String deviceId, {int maxRetries = 3}) async {
for (int i = 0; i < maxRetries; i++) {
try {
final result = await _methodChannelBle.connect(deviceId);
return result;
} catch (e) {
if (i == maxRetries - 1) rethrow;
await Future.delayed(Duration(seconds: 1));
}
}
return false;
}
2. Battery Optimization
- Limit scan duration (auto-stop after 5-10 seconds)
- Disconnect from devices when not in use
- Use notifications instead of polling
- Implement proper connection teardown
3. Platform Channel vs. Dedicated Plugin
Use Platform Channels When:
- Simple, app-specific BLE requirements
- Learning and prototyping
- Full control is needed
Create a Dedicated Plugin When:
- Reuse across multiple projects
- Team collaboration
- Open-source contribution
- Complex BLE functionality
Troubleshooting Common BLE Issues
1. Device Not Found When Scanning
Possible causes:
- Bluetooth is turned off
- Device is out of range
- Device is not advertising
- Missing location permissions (Android)
- Missing bluetooth permissions (iOS, Android 12+)
Solutions:
- Check Bluetooth state
- Ensure device is in advertising mode
- Verify all required permissions
- Add service UUID filters to scan for specific devices
2. Connection Failures
Possible causes:
- Device moved out of range
- Device battery is low
- Too many concurrent connections
- Device internal error
Solutions:
- Implement connection timeouts
- Add automatic reconnection logic
- Verify device is still advertising before connecting
- Check for platform-specific connection limits
3. Characteristic Read/Write Failures
Possible causes:
- Wrong characteristic UUID
- Insufficient permissions
- Characteristic requires authentication
- Characteristic has specific properties
Solutions:
- Verify characteristic UUIDs and properties
- Check for read/write permissions
- Handle platform-specific encryption requirements
- Implement proper error handling for each operation
Flutter BLE Implementation Cheat Sheet
Platform Channel Setup
// Method channel for one-off operations
static const MethodChannel _methodChannel = MethodChannel('com.example.flutter_ble/method');
// Event channel for continuous data
static const EventChannel _scanResultsChannel = EventChannel('com.example.flutter_ble/scan_results');
Common BLE Operations
Scanning
// Start scanning
Future<void> startScan() async {
await _methodChannel.invokeMethod('startScan');
}
// Stop scanning
Future<void> stopScan() async {
await _methodChannel.invokeMethod('stopScan');
}
// Get scan results stream
Stream<BleDevice> get scanResults =>
_scanResultsChannel.receiveBroadcastStream()
.map((data) => BleDevice.fromMap(Map<String, dynamic>.from(data)));
Connection
// Connect to device
Future<bool> connect(String deviceId) async {
return await _methodChannel.invokeMethod('connect', {'deviceId': deviceId});
}
// Disconnect from device
Future<void> disconnect(String deviceId) async {
await _methodChannel.invokeMethod('disconnect', {'deviceId': deviceId});
}
GATT Operations
// Discover services
Future<List<Map<String, dynamic>>> discoverServices(String deviceId) async {
return await _methodChannel.invokeMethod('discoverServices', {'deviceId': deviceId});
}
// Read characteristic
Future<List<int>> readCharacteristic(String deviceId, String serviceUuid, String characteristicUuid) async {
final result = await _methodChannel.invokeMethod('readCharacteristic', {
'deviceId': deviceId,
'serviceUuid': serviceUuid,
'characteristicUuid': characteristicUuid,
});
return List<int>.from(result);
}
// Write characteristic
Future<void> writeCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
List<int> value,
bool withResponse
) async {
await _methodChannel.invokeMethod('writeCharacteristic', {
'deviceId': deviceId,
'serviceUuid': serviceUuid,
'characteristicUuid': characteristicUuid,
'value': value,
'withResponse': withResponse,
});
}
// Set up notifications
Future<void> setNotification(
String deviceId,
String serviceUuid,
String characteristicUuid,
bool enable
) async {
await _methodChannel.invokeMethod('setNotification', {
'deviceId': deviceId,
'serviceUuid': serviceUuid,
'characteristicUuid': characteristicUuid,
'enable': enable,
});
}
Required Permissions
iOS (Info.plist)
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to BLE devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to connect to BLE devices</string>
Android (AndroidManifest.xml)
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- For Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
Common Error Handling
try {
await bleOperation();
} catch (e) {
if (e.toString().contains('BLUETOOTH_DISABLED')) {
// Prompt user to enable Bluetooth
} else if (e.toString().contains('PERMISSION_DENIED')) {
// Request necessary permissions
} else if (e.toString().contains('DEVICE_NOT_FOUND')) {
// Device is no longer available
} else {
// Generic error handling
}
}
Common BLE UUID
Service | UUID | Description |
---|---|---|
Generic Access | 1800 | Device name, appearance |
Generic Attribute | 1801 | Service changed |
Device Information | 180A | Manufacturer, model, etc. |
Battery Service | 180F | Battery level |
Heart Rate | 180D | Heart rate measurements |
Health Thermometer | 1809 | Temperature measurements |
From concept to deployment, Gurzu provides an end-to-end mobile development solution. Our application development prioritizes intuitive interfaces, seamless user experiences, and robust functionality.
Stuck on Flutter/Mobile App Development? Schedule a free consulting call with our Flutter expert!
You might also want to read “Mastering Flutter App Architecture with Bloc”