This document provides guidance for the migration from Able to the Kable library. Although the Kable library offers multiplatform support, this guide will focus on the Android support that Kable provides.
Able does not offer any scanning capabilities, it was expected that Android provided BLE scanning or 3rd party libraries
were used to acquire a BluetoothDevice
.
Kable on the other hand does provide built-in BLE scanning support via the Scanner. Using the Kable Scanner is
optional, whereas Android provided BLE scanning or 3rd party libraries may continue to be used with Kable. Once you've
acquired a BluetoothDevice
, the CoroutineScope.peripheral
extension function (which takes a BluetoothDevice
as
an argument) may be used.
To establish a connection in Able, the BluetoothDevice.connect(Context)
extension function was used. This function
would suspend until either failure or successful connection, returning a ConnectGattResult
providing the outcome of
the connection attempt.
To cancel a connection attempt, the encompassing CoroutineScope
would need to be cancelled, for example:
val job = launch {
bluetoothDevice.connect(context)
}
job.cancel() // Cancels the connection attempt.
In Able, a BluetoothDevice
was used to establish a connection then provide a Gatt
in a connected state. A
connect would be executed via a BluetoothDevice
and disconnect via a Gatt
. The Gatt
also provided functions for
performing I/O operations.
If a Gatt
lost connection, then you'd need to go back to the BluetoothDevice
to establish a new connection (and
obtain a new Gatt
object).
In Kable, this has been simplified to a unified Peripheral
, which allows both connection management
(connecting/disconnect) as well as performing I/O operations. This allows for holding a reference to a single object
representing the remote peripheral (whether it be in a pre-connected or connected state).
To establish a connection in Kable, you must first obtain a Peripheral
object, either via an advertisement (from
scanning) or from a BluetoothDevice
:
// See https://github.com/JuulLabs/kable#scanning for an example of obtaining an `Advertisement`.
val peripheral: Peripheral = scope.peripheral(advertisement)
// or
val peripheral: Peripheral = scope.peripheral(bluetoothDevice)
The Peripheral
object is initially in a disconnected state. To establish a connection, simply call the connect
function. The connect
function suspends until connected or failure occurs (exception is thrown on connect failure).
To cancel an in-flight connection attempt, call the disconnect
function. If a connection has already been established,
then the disconnect
function will close the connection. Similar to Able, cancelling the Coroutine scope that the
connect operation is being performed in, will cancel the connection attempt.
// `launch` used for illustrative purposes, to allow for `connect` to happen asynchronously so that
// `disconnect` can be called soon after.
val job = launch {
peripheral.connect()
}
// Cancels in-flight connection attempt, or closes connection if connection is already established.
peripheral.disconnect()
// or: job.cancel() will also cancel the in-flight connection attempt.
Because peripheral.connect()
suspends until a connection is established, you can perform BLE operations in a
sequential manner, for example:
peripheral.connect() // Suspends until connected (or failure occurs, in which case an exception is thrown).
peripheral.read(...) // Will not execute until connection is established.
peripheral.write(...) // Will not execute until previous read is complete.
In Kable, service discovery is performed automatically (upon connection), so there is no longer a need to explicitly
invoke a discoverServices
function, as was needed with Able.
With Able, to perform an I/O operation (such as writing a characteristic), you would need to first connect, then
retrieve the list of services via services: List
property of the Gatt
, or use the getService(UUID)
function. Then
iterate over the services to find the appropriate characteristic. Once found, the characteristic object would be passed
to the readCharacteristic
or writeCharacteristic
functions.
With Kable, characteristics and descriptors are defined ahead of time (can be defined before a connection is established), for example:
val characteristic = characteristicOf(
service = "00001815-0000-1000-8000-00805f9b34fb",
characteristic = "00002a56-0000-1000-8000-00805f9b34fb"
)
Characteristic
and Descriptor
objects (returned by the characteristicOf
and descriptorOf
functions,
respectively) can be reused. In other words, you can use the same Characteristic
object to both read
, write
and
observe
the same characteristic.
When executing an I/O operation, in Able a result object would be returned. The result object would need to be inspected
to check the GATT status (to ensure successful operation). When successful, the value
would need to be retrieved from
the result object. If a failure occurred an exception would be thrown. With Kable, the API has been simplified,
whereas a read operation (e.g. read
) returns the ByteArray
value of the characteristic when successful and an
exception is thrown on failure (including non-success response).
With Able, the connection state could be monitored by collecting the onConnectionStateChange
Flow
. If the
connection was lost/dropped, then collection of onConnectionStateChange
would need to be cancelled, a new connection
would need to be established and the new Gatt
's onConnectionStateChange
could be collected.
Since the same Peripheral
in Kable can be reused (connected, disconnect, and reconnected to again) its state
Flow
can be collected across connections. It can also be used for reconnection handling (e.g. executing connect
on Disconnected
state — and optionally, with an exponential backoff).
To observe a characteristic in Able, it was a manual process that involved:
- Connecting to remote peripheral (acquiring a
Gatt
object) - Discovering services
- Iterating over services to find characteristic of interest
- Enable notifications
- Write appropriate notification/indication descriptor
- Collect
onCharacteristicChanged
Flow
If connection was lost/dropped, the process would need to be repeated to establish a new characteristic change Flow
.
In Kable, observation is as simple as collecting the Flow
returned by observe
. Observations can be collected
even before a connection is established. The Flow
will remain active even on disconnect, and simply resume
observation (emission of changes) upon reconnection.
Be aware that the Flow
returned by observe
is not meant to be shared (i.e. it is not meant to have
multiple collectors). If you wish to have multiple subscribers, then you can call observe
again to obtain another
Flow
or use the shareIn
operator.
Kable leverages structured concurrency by making Peripheral
s children of the CoroutineScope
that they are
created with (via CoroutineScope.peripheral
extension function). When the CoroutineScope
is cancelled, then all
Peripheral
s created under that scope will disconnect and be disposed. This means Peripheral
s have a lifecycle
not longer than the CoroutineScope
they are created under.
Able did not have the notion of a parent/child relationship (connections being tied to a CoroutineScope
). To have
a similar behavior in Kable, you could use GlobalScope
to create Peripheral
s. Though, it is advised to
determine the appropriate lifecycle of a Peripheral
and use or create an appropriate CoroutineScope
. This
ensures that all Peripheral
s under the designated CoroutineScope
are properly disposed (prevent leaking of
connections). Using GlobalScope
is simply a means of forfeiting that safety and managing the connection yourself. In
other words, if GlobalScope
is used, be sure that when the Peripheral
connection is no longer needed that
disconnect
is called.