Kable
Kotlin Asynchronous Bluetooth Low Energy provides a simple Coroutines-powered API for interacting with Bluetooth Low Energy devices.
Usage is demonstrated with the SensorTag sample app.
Scanning
To scan for nearby peripherals, the Scanner
provides an advertisements
Flow
which is a stream of Advertisement
objects representing advertisements seen from nearby peripherals. Advertisement
objects contain information such as the peripheral's name and RSSI (signal strength).
Scanning begins when the advertisements
Flow
is collected and stops when the Flow
collection is terminated. A Flow
terminal operator (such as first
) may be used to scan until an advertisement is found that matches a desired predicate.
val advertisement = Scanner()
.advertisements
.first { it.name?.startsWith("Example") }
JavaScript: Scanning for nearby peripherals is supported, but only available on Chrome 79+ with "Experimental Web Platform features" enabled via: chrome://flags/#enable-experimental-web-platform-features
Peripheral
Once an Advertisement
is obtained, it can be converted to a Peripheral
via the CoroutineScope.peripheral
extension function. Peripheral
objects represent actions that can be performed against a remote peripheral, such as connection handling and I/O operations.
val peripheral = scope.peripheral(advertisement)
To configure a peripheral
, options may be set in the builder lambda:
val peripheral = scope.peripheral(advertisement) {
// Set peripheral configuration.
}
All platforms support an onServicesDiscovered
action (that is executed after service discovery but before observations are wired up):
val peripheral = scope.peripheral(advertisement) {
onServicesDiscovered {
// Perform any desired I/O operations.
}
}
Exceptions thrown in onServicesDiscovered
are propagated to the Peripheral
's connect
call.
Android
On Android targets, additional configuration options are available (all configuration directives are optional):
val peripheral = scope.peripheral(advertisement) {
onServicesDiscovered {
requestMtu(...)
}
transport = Transport.Le // default
phy = Phy.Le1M // default
}
JavaScript
On JavaScript, rather than processing a stream of advertisements, a specific peripheral can be requested using the CoroutineScope.requestPeripheral
extension function. Criteria (Options
) such as expected service UUIDs on the peripheral and/or the peripheral's name may be specified. When requestPeripheral
is called with the specified options, the browser shows the user a list of peripherals matching the criteria. The peripheral chosen by the user is then returned (as a Peripheral
object).
val options = Options(
optionalServices = arrayOf(
"f000aa80-0451-4000-b000-000000000000",
"f000aa81-0451-4000-b000-000000000000"
),
filters = arrayOf(
NamePrefix("Example")
)
)
val peripheral = scope.requestPeripheral(options).await()
Connectivity
Once a Peripheral
object is acquired, a connection can be established via the connect
function. The connect
method suspends until a connection is established and ready (or a failure occurs). A connection is considered ready when connected, services have been discovered, and observations (if any) have been re-wired. Service discovery occurs automatically upon connection.
Multiple concurrent calls to connect
will all suspend until connection is ready.
peripheral.connect()
To disconnect, the disconnect
function will disconnect an active connection, or cancel an in-flight connection attempt. The disconnect
function suspends until the peripheral has settled on a disconnected state.
peripheral.disconnect()
If the underlying subsystem fails to deliver the disconnected state then the disconnect
call could potentially stall indefinitely. To prevent this (and ensure underlying resources are cleaned up in a timely manner) it is recommended that disconnect
be wrapped with a timeout, for example:
// Allow 5 seconds for graceful disconnect before forcefully closing `Peripheral`.
withTimeoutOrNull(5_000L) {
peripheral.disconnect()
}
State
The connection state of a Peripheral
can be monitored via its state
Flow
.
peripheral.state.collect { state ->
// Display and/or process the connection state.
}
The state
will typically transition through the following State
s:
Disconnecting
state only occurs on Android platform. JavaScript and Apple-based platforms transition directly from Connected
to Disconnected
(upon calling disconnect
function, or when a connection is dropped).
I/O
Bluetooth Low Energy devices are organized into a tree-like structure of services, characteristics and descriptors; whereas characteristics and descriptors have the capability of being read from, or written to.
For example, a peripheral might have the following structure:
- Service S1 (
00001815-0000-1000-8000-00805f9b34fb
)- Characteristic C1
- Descriptor D1
- Descriptor D2
- Characteristic C2 (
00002a56-0000-1000-8000-00805f9b34fb
)- Descriptor D3 (
00002902-0000-1000-8000-00805f9b34fb
)
- Descriptor D3 (
- Characteristic C1
- Service S2
- Characteristic C3
To access a characteristic or descriptor, use the charactisticOf
or descriptorOf
functions, respectively.
In the above example, to access "Descriptor D3":
val descriptor = descriptorOf(
service = "00001815-0000-1000-8000-00805f9b34fb",
characteristic = "00002a56-0000-1000-8000-00805f9b34fb",
descriptor = "00002902-0000-1000-8000-00805f9b34fb"
)
Once connected, data can be read from, or written to, characteristics and/or descriptors via read
and write
functions.
The read
and write
functions throw NotReadyException
until a connection is established.
val data = peripheral.read(characteristic)
peripheral.write(descriptor, byteArrayOf(1, 2, 3))
Observation
Bluetooth Low Energy provides the capability of subscribing to characteristic changes by means of notifications and/or indications, whereas a characteristic change on a connected peripheral is "pushed" to the central via a characteristic notification and/or indication which carries the new value of the characteristic.
Characteristic change notifications/indications can be observed/subscribed to via the observe
function which returns a Flow
of the new characteristic data.
val observation = peripheral.observe(characteristic)
observation.collect { data ->
// Process data.
}
The observe
function can be called (and its returned Flow
can be collected) prior to a connection being established. Once a connection is established then characteristic changes will stream from the Flow
. If the connection drops, the Flow
will remain active, and upon reconnecting it will resume streaming characteristic changes.
Failures related to notifications/indications are propagated via the observe
Flow
, for example, if the associated characteristic is invalid or cannot be found, then a NoSuchElementException
is propagated via the observe
Flow
.
In scenarios where an I/O operation needs to be performed upon subscribing to the observe
Flow
, an onSubscribe
action may be specified:
val observation = peripheral.observe(characteristic) {
// Perform desired I/O operations upon collecting from the `observe` Flow, for example:
peripheral.write(descriptor, "ping".toByteArray())
}
observation.collect { data ->
// Process data.
}
In the above example, "ping"
will be written to the descriptor
when:
- Connection is established (while the returned
Flow
is active); and - After the observation is spun up (i.e. after enabling notifications or indications)
The onSubscription
action is useful in situations where an initial operation is needed when starting an observation (such as writing a configuration to the peripheral and expecting the response to come back in the form of a characteristic change).
Structured Concurrency
Peripheral objects/connections are scoped to a Coroutine scope. When creating a Peripheral
, the CoroutineScope.peripheral
extension function is used, which scopes the returned Peripheral
to the CoroutineScope
receiver. If the CoroutineScope
receiver is cancelled then the Peripheral
will disconnect and be disposed.
Scanner()
.advertisements
.filter { advertisement -> advertisement.name?.startsWith("Example") }
.map { advertisement -> scope.peripheral(advertisement) }
.onEach { peripheral -> peripheral.connect() }
.launchIn(scope)
delay(60_000L)
scope.cancel() // All `peripherals` will implicitly disconnect and be disposed.
Peripheral.disconnect
is the preferred method of disconnecting peripherals, but disposal via Coroutine scope cancellation is provided to prevent connection leaks.
Setup
Gradle
Kable can be configured via Gradle Kotlin DSL as follows:
Multiplatform
plugins {
id("com.android.application") // or id("com.android.library")
kotlin("multiplatform")
}
repositories {
mavenCentral()
}
kotlin {
android()
js().browser() // and/or js().node()
macosX64()
iosX64()
iosArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.juul.kable:core:$version")
}
}
}
}
android {
// ...
}
Note that Apple-based targets (e.g. macosX64
) require Coroutines with multithread support for Kotlin/Native (more specifically: Coroutines library artifacts that are suffixed with -native-mt
). Kable is configured to use -native-mt
as a transitive dependency for Apple-based targets.
Platform-specific
repositories {
mavenCentral()
}
dependencies {
implementation("com.juul.kable:core:$version")
}
License
Copyright 2020 JUUL Labs, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.