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.
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).
The Scanner may be configured via the following DSL (shown are defaults, when not specified):
val scanner = Scanner {
services = null
logging {
engine = SystemLogEngine
level = Warnings
format = Multiline
}
}To filter scan results at the system level (recommended), specify a list of services the remote peripheral is advertising, for example:
val scanner = Scanner {
services = listOf(
uuidFrom("f000aa80-0451-4000-b000-000000000000"),
uuidFrom("f000aa81-0451-4000-b000-000000000000"),
)
}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
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.
}By default, Kable only logs a small number of warnings when unexpected failures occur. To aid in debugging, additional
logging may be enabled and configured via the logging DSL, for example:
val peripheral = scope.peripheral(advertisement) {
logging {
level = Events // or Data
}
}The available log levels are:
Warnings: Logs warnings when unexpected failures occur (default)Events: Same asWarningsplus logs all events (e.g. writing to a characteristic)Data: Same asEventsplus string representation of I/O data
Available logging settings are as follows (all settings are optional; shown are defaults, when not specified):
val peripheral = scope.peripheral(advertisement) {
logging {
engine = SystemLogEngine
level = Warnings
format = Multiline
data = Hex
}
}The format of the logs can be either Compact (on a single line per log) or Multiline (spanning multiple lines for
details):
Compact |
Multiline (default) |
|---|---|
example message(detail1=value1, detail2=value2, ...) |
example message |
Display format of I/O data may be customized, either by configuring the Hex representation, or by providing a
DataProcessor, for example:
val peripheral = scope.peripheral(advertisement) {
logging {
data = Hex {
separator = " "
lowerCase = false
}
// or...
data = DataProcessor { bytes ->
// todo: Convert `bytes` to desired String representation, for example:
bytes.joinToString { byte -> byte.toString() } // Show data as integer representation of bytes.
}
}
}I/O data is only shown in logs when logging level is set to Data.
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.
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
}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()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()
}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 States:
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).
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))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
Flowis 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).
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.
Kable can be configured via Gradle Kotlin DSL as follows:
plugins {
id("com.android.application") // or id("com.android.library")
kotlin("multiplatform")
}
repositories {
mavenCentral()
}
kotlin {
android()
js().browser()
macosX64()
iosX64()
iosArm64()
sourceSets {
val commonMain by getting {
dependencies {
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}")
implementation("com.juul.kable:core:${kableVersion}")
}
}
val androidMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutinesVersion}")
}
}
val macosX64Main by getting {
dependencies {
// Need to specify the Coroutines artifact specific for the target platform (`-macosx64`):
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-macosx64:${coroutinesVersion}-native-mt") {
version {
// `strictly` needed to make sure Gradle uses `-native-mt` version.
strictly("${coroutinesVersion}-native-mt")
}
}
}
}
val iosX64Main by getting {
dependencies {
// Need to specify the Coroutines artifact specific for the target platform (`-iosx64`):
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-iosx64:${coroutinesVersion}-native-mt") {
version {
// `strictly` needed to make sure Gradle uses `-native-mt` version.
strictly("${coroutinesVersion}-native-mt")
}
}
}
}
val iosArm64Main by getting {
dependencies {
// Need to specify the Coroutines artifact specific for the target platform (`-iosarm64`):
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-iosarm64:${coroutinesVersion}-native-mt") {
version {
// `strictly` needed to make sure Gradle uses `-native-mt` version.
strictly("${coroutinesVersion}-native-mt")
}
}
}
}
}
}
android {
// ...
}Note that for compatibility with Kable, Native targets (e.g. macosX64) require
Coroutines with multithread support for Kotlin/Native (more specifically: Coroutines library artifacts that are
suffixed with -native-mt).
repositories {
mavenCentral()
}
dependencies {
implementation("com.juul.kable:core:$version")
}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.