Need help with kable?
Click the “chat” button below for chat support from the developer who created it, or find similar developers for support.

About the developer

JuulLabs
289 Stars 28 Forks Apache License 2.0 86 Commits 33 Opened issues

Description

Kotlin Asynchronous Bluetooth Low-Energy

Services available

!
?

Need anything else?

Contributors list

badge badge badge badge Slack

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
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
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") }

Android

Android offers additional settings to customize scanning. They are available via the

scanSettings
property in the
Scanner
builder DSL. Simply set
scanSettings
property to an Android
ScanSettings
object, for example:
val scanner = Scanner {
    scanSettings = ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
        .build()
}

The

scanSettings
property is only available on Android and is considered a Kable obsolete API, meaning it will be removed when a DSL specific API becomes available.

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)

Configuration

To configure a

peripheral
, options may be set in the builder lambda:
val peripheral = scope.peripheral(advertisement) {
    // Set peripheral configuration.
}

Logging

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 as
    Warnings
    plus logs all events (e.g. writing to a characteristic)
  • Data
    : Same as
    Events
    plus 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
detail1: value1
detail2: value2
...
|

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
.

When logging, the identity of the peripheral is prefixed on log messages to differentiate messages when multiple peripherals are logging. The identifier (for the purposes of logging) can be set via the

identifier
property:
val peripheral = scope.peripheral(advertisement) {
    logging {
        identifier = "Example"
    }
}

The default (when not specified, or set to

null
) is to use the platform specific peripheral identifier:
  • Android: Hardware (MAC) address (e.g. "00:11:22:AA:BB:CC")
  • Apple: The UUID associated with the peer
  • JavaScript: A
    DOMString
    that uniquely identifies a device

Service Discovery

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
.

peripheral.state.collect { state ->
    // Display and/or process the connection state.
}

The

state
will typically transition through the following
State
s:

Connection states

Disconnecting
state only occurs on Android platform. JavaScript and Apple-based platforms transition directly from
Connected
to
Disconnected
.

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
        )
  • 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
, for example, if the associated characteristic is invalid or cannot be found, then a

NoSuchElementException
is propagated via the
observe
.

In scenarios where an I/O operation needs to be performed upon subscribing to the

observe
, 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

Maven Central

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() 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.

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.

We use cookies. If you continue to browse the site, you agree to the use of cookies. For more information on our use of cookies please see our Privacy Policy.