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

Pub Version

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 init you 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...
    },
  );
}
  • BLEDevice is an abstract class you should check the correct implementation, e.g.BLEHeartRateManager will return a list of BLEHeartRateDevice.

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 connectDevice a connection request will be launched if no connection is made in that period the request will be canceled, unless isDeviceReconnectionEnabled is 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.

  • BLEDevice is an abstract class you should check the correct implementation, e.g.BLEHeartRateManager will return states of BLEHeartRateDevice.
@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 rrIntervals will 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 Logger instance 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.

  • connectToDevice returns a StreamSubscription, be sure to keep a reference to cancel the connection.
  • connectionTimeout behaviour 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 connectDevice function.
  • BLEDeviceDisconnected
    • failure - You can provide your own failure text or use ConnectionStateUpdate.failure.
    • willReconnect - Pass the value of isDeviceReconnectionEnabled property on BLEManager.
  • 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
  }
}
  • _connectedDevice is a reference of the current connected BLEScaleDevice.

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

Libraries

rook_ble