Rook ble package
This package handles scan and connection to BLE devices.
Features
- Check for bluetooth and location (android) capabilities.
- Scan for BLE devices.
- Connect to BLE devices.
- Obtain measurements and information from BLE devices.
Supported devices
- Heart rate devices:
- BPM.
- RR interval.
- Contact status.
- Battery level.
Installation
flutter pub add rook_users
This package requires flutter 3.3.0 or higher.
Getting started
To get authorization to use this package, you'll need to install and configure the rook-auth package.
Android configuration
Add the following permissions to your AndroidManifest.xml:
<!-- Scan BLE peripherals -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Android 6+ (sdk 23) bluetooth permissions-->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Android 12+ (sdk 31) bluetooth permissions-->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Filter your app from devices that do not have BLE capabilities. -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
Add the following dependency to your build.gradle (app).
implementation "com.polidea.rxandroidble2:rxandroidble:1.11.1"
In your build.gradle (app) set your min and target sdk version like below:
minSdk 26
targetSdk 33
In your MainActivity class add the following snippet:
RxJavaPlugins.setErrorHandler { throwable ->
if (throwable is UndeliverableException && throwable.cause is BleException) {
return@setErrorHandler // ignore BleExceptions since we do not have subscriber
} else {
throw throwable
}
}
If you are using ProGuard add the following snippet to your proguard-rules.pro file:
-keep class com.signify.hue.** { *; }
IOS configuration
Add the following permissions to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>The app uses bluetooth to find, connect and transfer data between different devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>The app uses bluetooth to find, connect and transfer data between different devices</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
Logging
If you want to see the logs generated by this package
Install the logging package:
logging: ">=1.0.0 <2.0.0"
Add the following snippet in your main() function:
void main() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
if (kDebugMode) {
print('${record.level.name}: ${record.time}: ${record.message}');
}
});
runApp(RookApp());
}
Usage (Basic)
Import rook ble.
import 'package:rook_ble/rook_ble.dart';
Classes included
This package contains the following classes each one to manage an specific BLE device type.
- BLEManager - Abstract class, you can use it to implement your own BLE managers.
- BLEHeartRateManager - Extends from BLEManager, handles all operation related to Heart rate devices.
Initializing
Before using any BLEManager class you MUST initialize it.
Call init to get a BLEInitializationResult.
void initializeBLE() async {
final result = await manager.init();
}
- The first time you call
inityou must expect some delay, so it's recommended to wrap the initialization with a delayed future:
@override
void initState() {
Future.delayed(const Duration(seconds: 1), () {
manager.init();
});
super.initState();
}
To retrieve the current state of BLEManager there are 2 approaches:
Future approach
Use the returned BLEInitializationResult to check for initialization errors.
class BLEInitializationResult {
final bool success; // Whether the initialization was completed or not.
final BLEState state; // Current state of [BLEManager].
final String? error; // Description of what went wrong. This will only be available when [state] is [BLEState.errorInitializing].
}
enum BLEState {
none, // BLE has been created and needs to be initialized.
incompatible, // This device does not support BLE.
missingPermissions, // BLE is not available because the device does not meet the required permissions.
bluetoothIsOff, // BLE is not available because the device's bluetooth is off.
locationIsOff, // BLE is not available because the device's location (GPS) is off.
initialized, // The BLE component has been successfully initialized and is ready to be used.
errorInitializing, // There was an error initializing, try again.
disposed // The BLE component has been disposed and should not be used anymore, attempting to use it will result in errors.
}
Once the error is resolved call init again.
Stream approach
You can also listen to the state stream to receive updates about the initialization process in
real time, when using this approach you will only need to call init once and update your UI with
an StreamBuilder.
@override
Widget build(BuildContext context) {
return StreamBuilder<BLEState>(
stream: manager.state,
builder: (context, snapshot) {
// Your UI here...
},
);
}
Scanning
Call startDevicesDiscovery to search advertising devices, depending on the BLEManager
implementation you are using, the results will be filtered, e.g.BLEHeartRateManager will only
identify devices that meet the heart rate specification.
This scan will last forever until you call stopDevicesDiscovery
void toggleScanner(bool enable) {
if (enable) {
manager.startDevicesDiscovery();
} else {
manager.stopDevicesDiscovery();
}
}
Each one of these functions will return a boolean indicating if the operation was successful.
Although we recommend to listen to the isDiscovering stream instead.
@override
Widget build(BuildContext context) {
return StreamBuilder<bool>(
stream: manager.isDiscovering,
builder: (context, snapshot) {
final discovering = snapshot.data ?? false;
if (discovering) {
return ElevatedButton(
onPressed: manager.stopDevicesDiscovery,
child: const Text('Stop scan'),
);
} else {
return ElevatedButton(
onPressed: manager.startDevicesDiscovery,
child: const Text('Start scan'),
);
}
},
);
}
The discovered devices will be emitted into the discoveredDevices stream.
@override
Widget build(BuildContext context) {
return StreamBuilder<List<BLEDevice>>(
stream: manager.discoveredDevices,
builder: (context, snapshot) {
// Your UI here...
},
);
}
BLEDeviceis an abstract class you should check the correct implementation, e.g.BLEHeartRateManagerwill return a list ofBLEHeartRateDevice.
Last used device
You can save and retrieve the last used device that was connected to your app, this will allow your users to quickly connect to device they have previously used.
To save a device call storeDevice and provide a BLEDevice implementation. It will return a bool
indicating if the operation was completed successfully.
This package is capable of saving one device of each type which means it can save
one BLEHeartRateDevice and if you call storeDevice with another BLEHeartRateDevice instance,
the previous BLEHeartRateDevice will be replaced with the new one. But if you call it again and
provide e.g. a BLEScaleDevice instance it will be stored along with the previously
stored BLEHeartRateDevice.
void selectDevice(BLEDevice device) async {
final success = await manager.storeDevice(device);
}
To retrieve a device call getStoredDevice. The returned value will depend on the BLEManager you
are using e.g.BLEHeartRateManager will return a BLEHeartRateDevice instance or throw an
exception if no device of that type was found.
void getLastUsedDevice() async {
try {
final device = await manager.getStoredDevice();
} catch (error) {
// Manage device not found.
}
}
To delete a device call deleteStoredDevice. It will return a bool indicating if the operation was
completed successfully. The device to delete will depend on the BLEManager you are using
e.g.BLEHeartRateManager will delete the stored BLEHeartRateDevice.
Device connection
To connect to a device call connectDevice and provide a BLEDevice implementation. It will return
a bool indicating if the connection request was created.
WARNING:
Some android devices cannot scan and connect at the same time, so you MUST stop scanning before
calling connectDevice.
void selectDevice(BLEDevice device) async {
final discoveryStopped = await manager.stopDevicesDiscovery();
final deviceSaved = await manager.storeDevice(device);
final requestLaunched = await manager.connectDevice(device);
}
- Once called
connectDevicea connection request will be launched if no connection is made in that period the request will be canceled, unlessisDeviceReconnectionEnabledis true (more about reconnection in the next sections).
To disconnect from a device call disconnectDevice. It will return a bool indicating if the
connection was canceled.
void disconnect() async {
final disconnected = await manager.disconnectDevice(device);
}
Listening to connection changes
To listen for the current connected device state listen to deviceState stream.
BLEDeviceis an abstract class you should check the correct implementation, e.g.BLEHeartRateManagerwill return states ofBLEHeartRateDevice.
@override
Widget build(BuildContext context) {
return StreamBuilder<BLEDeviceState<BLEDevice>>(
stream: manager.deviceState,
builder: (context, snapshot) {
// Your UI here...
},
);
}
There are 4 possible states a device can have plus 1 error state:
| State | Description | Properties | Properties type |
|---|---|---|---|
| BLEDeviceConnecting | Indicates that connection process with a device has begun. | N/A | N/A |
| BLEDeviceConnected | Indicates that a device has been connected. | device - Device witch connection has been established. | device - |
| BLEDeviceDisconnecting | Indicates that disconnection process with a device has begun. | N/A | N/A |
| BLEDeviceDisconnected | Indicates that current sensor has lost its connection. | failure - Disconnection reason. willReconnect - True if the disconnected device will be reconnected automatically. | failure - String? willReconnect - bool |
| BLEDeviceError | There was a non connection related error with the device. | message - Error description. | message - String |
Device reconnection
You can use configureDeviceReconnection to enable/disable reconnection.
void toggleReconnection(bool enable) {
manager.configureDeviceReconnection(enable);
}
If reconnection is enabled when a connected device loses connection BLEManager will automatically
launch a connection request. If that request fails it will launch another one and so on.
You can check if reconnection is enable using isDeviceReconnectionEnabled or when a disconnection
state is added to the deviceState stream check for the BLEDeviceDisconnected.willReconnect flag.
Release resources
If you are not using the BLEManager instance anymore you need to release any resources or pending
jobs (scan or connection requests) call dispose. E.g. on your widget dispose function.
class BLEScreen extends StatefulWidget {
const BLEScreen({Key? key}) : super(key: key);
@override
State<BLEScreen> createState() => _BLEScreenState();
}
class _BLEScreenState extends State<BLEScreen> {
final BLEManager manager = BLEManager();
@override
Widget build(BuildContext context) {
return MyCustomUI();
}
@override
void dispose() {
manager.dispose();
super.dispose();
}
}
Usage (Heart rate)
This section describes how to get measurements from heart rate devices, if you are not using heart rate devices skip this section.
Retrieving HR data
Once a connection request launched with connectDevice succeeds a subscription to it's heart rate
service will be created, every time the sensor sends HR data a HeartRateMeasurement will be
created and added to the measurements stream, you can listen to this stream and update your UI.
@override
Widget build(BuildContext context) {
return StreamBuilder<HeartRateMeasurement>(
stream: manager.measurements,
builder: (context, snapshot) {
// Your UI here...
},
);
}
The HeartRateMeasurement object is described below:
class HeartRateMeasurement {
final BLEHeartRateDeviceContact deviceContact; // Current contact status of a BLEHeartRateDevice with user's body.
final int heartRate; // Current heart rate in bpm.
// Current RR intervals in seconds from sensor (if the sensor does not support RR interval calculation, a calculation of our own will be returned).
final List<double> rrIntervals;
}
enum BLEHeartRateDeviceContact {
contact, // Device has correct contact.
noContact, // Device has incorrect or no contact.
notAvailable // Device does not support contact status.
}
- Some heart rate devices don't send RR intervals in every measurement, in those cases
rrIntervalswill return an empty list.
Retrieving battery level
To request the battery level call readBatteryLevel. It will return an int indicating the remaining
battery percentage.
void getRemainingBatteryPercentage() async {
final level = await manager.readBatteryLevel();
}
It's safe to call readBatteryLevel multiple times but be ware of not overloading the device with
requests, we recommend a 10 second delay between every request.
void watchBatteryLevel() {
Timer.periodic(const Duration(seconds: 10), (timer) async {
final level = await manager.readBatteryLevel();
});
}
Additional information
Last used devices are stored on device's shared preferences, if your app is also using shared
preferences keep in mind that calling clear on the SharedPreferences instance will also delete
this package registries.
Building with rook_ble
The rook_ble package allows you create new ble managers so you can support more devices, this can be
done extending from the BLEManager and BLEDevice classes.
In the following sections will cover the case for adding support for a BLE scale.
Setup
Install the flutter_reactive_ble and logging package with version constraints like below:
flutter_reactive_ble: ">=5.0.0 <6.0.0"
logging: ">=1.0.0 <2.0.0"
BLEDevice
Create a BLEScaleDevice extending from BLEDevice, this class already has the mac and name
fields.
class BLEScaleDevice extends BLEDevice {
BLEScaleDevice({required super.mac, required super.name});
}
Apart from the mac and the name of the device, the model needs to be stored as well.
class BLEScaleDevice extends BLEDevice {
final String model;
BLEScaleDevice({
required super.mac,
required super.name,
required this.model,
});
}
At this stage the class can be used with BLEManager but the model filed won't be stored and
the == function won't work as expected, so we need to override the toMap, toString, ==
and hashCode functions.
class BLEScaleDevice extends BLEDevice {
final String model;
BLEScaleDevice({
required super.mac,
required super.name,
required this.model,
});
factory BLEScaleDevice.fromMap(Map<String, dynamic> map) {
return BLEScaleDevice(
mac: map['mac'] as String,
name: map['name'] as String,
model: map['model'] as String,
);
}
@override
Map<String, dynamic> toMap() {
return {
'mac': mac,
'name': name,
'model': model,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
super == other &&
other is BLEScaleDevice &&
runtimeType == other.runtimeType &&
model == other.model;
@override
int get hashCode => super.hashCode ^ model.hashCode;
}
Also a fromMap factory was added to recreate instances later on.
BLEManager
BLEManager contains basic operations to work with BLE devices, to use it with a BLEScaleDevice,
we need to create a BLEScaleManager class extending from BLEManager<BLEScaleDevice>.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
}
Factory
BLEManager takes care of scanning and device storage management (create, retrieve, delete) but in
order to do so it needs to know how to create instances of BLEScaleDevice.
BLEManager allows you to provide a factory, it needs to extend from BLEDeviceFactory and
override the fromDiscoveredDevice and fromMap functions.
In each function you must check if the received type is equal to the factory type, in this
case: BLEScaleDevice, and then describe how to create an instance of you BLEDevice. Also remember
returning a call to super at the end.
class BLEScaleDeviceFactory extends BLEDeviceFactory<BLEScaleDevice> {
@override
BLEScaleDevice? fromDiscoveredDevice(Type type, DiscoveredDevice device) {
if (type == BLEScaleDevice) {
return BLEScaleDevice(
mac: device.id,
name: device.name,
model: "Scale-${device.rssi}",
);
}
return super.fromDiscoveredDevice(type, device);
}
@override
BLEScaleDevice? fromMap(Type type, Map<String, dynamic> map) {
if (type == BLEScaleDevice) {
return BLEScaleDevice.fromMap(map);
}
return super.fromMap(type, map);
}
}
If you have problems overriding the fromDiscoveredDevice function add the next import
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
Services
To scan and retrieve measurements a service and a characteristic UUID will be needed, create a constants file with your UUIDs.
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
final Uuid scaleService = Uuid([0x18, 0x1D]);
final Uuid scaleCharacteristic = Uuid([0x2A, 0x9D]);
Constructor
The BLEManager constructor requires some parameters:
- logger - A
Loggerinstance with a tag. - services - The list of UUID of the services you need to scan (in this case the
scaleService). - deviceFactory - A factory describing how to create BLEDevice instances (in this
case
BLEScaleDevice).
We'll provide them with a call to super().
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
BLEScaleManager()
: super(
logger: Logger('BLEScaleManager'),
services: [scaleService],
deviceFactory: BLEScaleDeviceFactory(),
) {
// Your code here...
}
}
With this configuration BLEScaleManager already takes care of the following functions, so you
won't need to implement them:
- init()
- getStoredDevice()
- storeDevice()
- deleteStoredDevice()
- isBluetoothEnabled()
- requestEnableBluetooth()
- requestEnableLocation()
- configureDeviceReconnection()
- startDevicesDiscovery()
- stopDevicesDiscovery()
- readDeviceRegister()
At this moment your BLEScaleManager should look like below:
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
@override
// TODO: implement deviceState
Stream<BLEDeviceState<BLEScaleDevice>> get deviceState => throw UnimplementedError();
BLEScaleManager()
: super(
logger: Logger('BLEScaleManager'),
services: [scaleService],
deviceFactory: BLEScaleDeviceFactory(),
) {
// Your code here...
}
@override
Future<bool> connectDevice(BLEScaleDevice device) {
// TODO: implement connectDevice
throw UnimplementedError();
}
@override
Future<bool> disconnectDevice() {
// TODO: implement disconnectDevice
throw UnimplementedError();
}
}
Connection
To connect a device use connectToDevice on the ble property, providing a mac address safeMac
getter will return an upper-cased version of the mac string to avoid issues where some device's
macs are received in lower case.
connectToDevicereturns a StreamSubscription, be sure to keep a reference to cancel the connection.connectionTimeoutbehaviour won't be the same on all Android devices as every brand has it's own bluetooth implementation, however if the connection attempt fails you will receive an update on the same stream.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
@override
Future<bool> connectDevice(BLEScaleDevice device) {
if (_connectionSubscription == null) { // Avoid multiple connection requests
_connectionSubscription = ble.connectToDevice(
id: device.safeMac,
servicesWithCharacteristicsToDiscover: {
scaleService: [scaleCharacteristic],
},
connectionTimeout: const Duration(seconds: 10),
).listen(
(result) {
if (result.connectionState == DeviceConnectionState.connecting) {
// Update deviceState with BLEDeviceConnecting
} else if (result.connectionState == DeviceConnectionState.connected) {
setUpServices(device);
} else if (result.connectionState == DeviceConnectionState.disconnecting) {
// Update deviceState with BLEDeviceDisconnecting
} else {
// Cancel the _connectionSubscription
// Update deviceState with BLEDeviceDisconnected
_reconnect(device); // More about this on reconnection section
}
},
onError: (error) {
// Update deviceState with BLEDeviceError
},
);
// Return true or false
}
// Return false
}
}
The setUpServices function will be called every time a successful connection is established, in
this function we'll subscribe to the scale notifications and finally (if everything it's okay),
update the deviceState with a BLEDeviceConnected state.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
void setUpServices(BLEScaleDevice device) async {
await ble.discoverServices(device.safeMac); // Optional
final characteristic = QualifiedCharacteristic(
characteristicId: scaleCharacteristic,
serviceId: scaleService,
deviceId: device.safeMac,
);
ble.subscribeToCharacteristic(characteristic).listen((data) {
if (data.isNotEmpty) {
// Decode data
// Notify of new measurements
}
});
// Update deviceState with BLEDeviceConnected
}
}
To disconnect from the device, cancel the StreamSubscription.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
@override
Future<bool> disconnectDevice() async {
try {
await _connectionSubscription?.cancel();
// Update deviceState with BLEDeviceDisconnected
return true;
} catch (error) {
logger.severe('disconnect error: $error');
return false;
}
}
}
Connection state is handled by the deviceState stream this stream accepts any child
of BLEDeviceState<T extends BLEDevice>. Go
to Connection changes to learn more about connection states.
Some BLEDeviceState child require parameters:
- BLEDeviceConnected
- device - The device provided to the
connectDevicefunction.
- device - The device provided to the
- BLEDeviceDisconnected
- failure - You can provide your own failure text or use
ConnectionStateUpdate.failure. - willReconnect - Pass the value of
isDeviceReconnectionEnabledproperty onBLEManager.
- failure - You can provide your own failure text or use
- BLEDeviceError
- message - You can provide your own error text.
Reconnection
BLEManager has the isDeviceReconnectionEnabled getter to check if the device should reconnect
after a disconnection event is received.
Using this flag we can create a reconnection function to check if isDeviceReconnectionEnabled is
true then start a timer if by the end of the timer isDeviceReconnectionEnabled is still true,
we'll call connectDevice.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
void _reconnect(BLEScaleDevice device) {
if (isDeviceReconnectionEnabled) {
_reconnectionTimer = Timer(
const Duration(seconds: 3), () {
if (isDeviceReconnectionEnabled) {
connectDevice(device);
} else {
logger.warning('Reconnection was disabled');
}
},
);
}
}
}
Read characteristics
To perform a single read to a characteristic use readDeviceRegister proving an instance
of QualifiedCharacteristic. It will return a Uint8List from the received data.
In this example we are reading the battery level of the connected scale.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
Future<int> readBatteryLevel() async {
if (_connectedDevice == null) {
throw Exception('No connected device');
}
final bytes = await readDeviceRegister(QualifiedCharacteristic(
characteristicId: batteryCharacteristic,
serviceId: batteryService,
deviceId: _connectedDevice!.safeMac,
));
// Transform bytes
// Return value
}
}
_connectedDeviceis a reference of the current connectedBLEScaleDevice.
dispose overriding.
If you want to override the dispose function. Remember to call to super
before your code.
class BLEScaleManager extends BLEManager<BLEScaleDevice> {
@override
void dispose() {
super.dispose();
// Your code here...
}
}