Skip to content

JuulLabs/kable

Repository files navigation

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 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 {
    filters {
        match {
            name = Filter.Name.Exact("My device")
        }
    }
    logging {
        engine = SystemLogEngine
        level = Warnings
        format = Multiline
    }
}

Scan results can be filtered by providing a list of Filters via the filters DSL. The following filters are supported:

Filter Android Apple JavaScript
Service ✓✓ ✓✓* ✓✓
Name ✓✓ ✓✓
NamePrefix ✓✓
Address ✓✓
ManufacturerData ✓✓ ✓✓

✓✓ = Supported natively
✓ = Support provided by Kable via flow filter
✓✓* = Supported natively if the only filter type used, otherwise falls back to flow filter

Tip

When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is recommended to provide only Filter.Service filters (and at least one) — as it is natively supported on all platforms.

When filters are specified, only Advertisements that match at least one Filter will be emitted. For example, if you had the following peripherals nearby when performing a scan:

ID Name Services
D1 "SensorTag" 0000aa80-0000-1000-8000-00805f9b34fb
D2 f484e2db-2efa-4b58-96be-f89372a3ef82
D3 "Example" 8d7798c7-15bd-493f-a935-785305946870,
67bebb9e-6372-4de6-a7bf-e0384583929e

To have peripherals D1 and D3 emitted during a scan, you could use the following filters:

val scanner = Scanner {
    filters {
        match {
            services = listOf(uuidFrom("0000aa80-0000-1000-8000-00805f9b34fb")) // SensorTag
        }
        match {
            name = Filter.Name.Prefix("Ex")
        }
    }
}

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 (for example) the first advertisement is found matching the specified filters:

val advertisement = Scanner {
    filters {
        match {
            name = Filter.Name.Exact("Example")
        }
    }
}.advertisements.first()

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

Note

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 Peripheral builder function:

val peripheral = Peripheral(advertisement) {
    // Configure peripheral.
}

Peripheral objects represent actions that can be performed against a remote peripheral, such as connection handling and I/O operations. Peripheral objects are themselves CoroutineScopes, and coroutines can be launched from them:

peripheral.launch {
    // Long running task that will be cancelled when peripheral
    // is disposed (i.e. `Peripheral.cancel()` is called).
}

Important

When a Peripheral is no longer needed, it should be disposed via cancel:

peripheral.cancel()

Tip

launched coroutines from a Peripheral object are permitted to run until Peripheral.cancel() is called (i.e. can span across reconnects); for tasks that should only run for the duration of a single connection (i.e. shutdown on disconnect), launch via the CoroutineScope returned from Peripheral.connect instead.

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 = 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 = 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 = 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 = 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 = 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 = Peripheral(advertisement) {
    autoConnectIf { false } // default
    onServicesDiscovered {
        requestMtu(...)
    }
    transport = Transport.Le // default
    phy = Phy.Le1M // default
}

autoConnect

Per the connectGatt documentation, autoConnect determines:

Whether to directly connect to the remote device (false) or to automatically connect as soon as the remote device becomes available (true).

With respect to connecting:

autoConnect value connect timeout
false ~30 seconds
true Never

Per answer to "What exactly does Android's Bluetooth autoConnect parameter do?":

Direct connect has a different scan interval and scan window at a higher duty than auto connect, meaning it will dedicate more radio time to listen for connectable advertisements for the remote device, i.e. the connection will be established faster.

One possible strategy for a fast initial connection attempt that falls back to lower battery usage connection attempts is:

val autoConnect = MutableStateFlow(false)

val peripheral = Peripheral {
    autoConnectIf { autoConnect.value }
}

while (peripheral.state.value != Connected) {
    autoConnect.value = try {
        peripheral.connect()
        false
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        true
    }
}

JavaScript

On JavaScript, rather than processing a stream of advertisements, a specific peripheral can be requested using the requestPeripheral 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). If user cancels the dialog, then requestPeripheral returns null.

val options = Options {
    filters {
        match {
            name = Filter.Name.Prefix("Example")
        }
    }
    optionalServices = listOf(
        uuidFrom("f000aa80-0451-4000-b000-000000000000"),
        uuidFrom("f000aa81-0451-4000-b000-000000000000"),
    )
}
val peripheral = requestPeripheral(options)

After the user selects a device to pair with this origin, the origin is allowed to access any service whose UUID was listed in the services list in any element of options.filters or in options.optionalServices.

This implies that if developers filter just by name, they must use optionalServices to get access to any services.

Web Bluetooth: 4. Device Discovery

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.

Tip

Multiple concurrent calls to connect will all suspend until connection is ready.

peripheral.connect()

The connect function returns a CoroutineScope that can be used to launch tasks that should run until peripheral disconnects. When disconnect is called, any coroutines [launch]ed from the CoroutineScope returned by connect will be cancelled prior to performing the underlying disconnect process.

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

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 States:

Connection states

Note

Disconnecting state is skipped on Apple and JavaScript when connection closure is initiated by peripheral (or peripheral goes out-of-range).

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 characteristicOf or descriptorOf functions, respectively. These functions lazily search for the first match (based on UUID) in the GATT profile when performing I/O.

When performing I/O operations on a characteristic (read, write, observe), the properties of the characteristic are taken into account when finding the first match. For example, when performing a write with a WriteType of WithoutResponse, the first characteristic matching the expected UUID and having the writeWithoutResponse property will be used.

In the above example, to lazily access "Descriptor D3":

val descriptor = descriptorOf(
    service = "00001815-0000-1000-8000-00805f9b34fb",
    characteristic = "00002a56-0000-1000-8000-00805f9b34fb",
    descriptor = "00002902-0000-1000-8000-00805f9b34fb"
)

Alternatively, a characteristic or descriptor may be obtained by traversing the Peripheral.services. This is useful when multiple characteristics or descriptors have the same UUID. Objects obtained from the Peripheral.services hold strong references to the underlying platform types, so special care must be taken to properly remove references to objects retrieved from Peripheral.services when no longer needed.

To access "Descriptor D3" using a discovered descriptor:

val services = peripheral.services.value ?: error("Services have not been discovered")
val descriptor = services
    .first { it.serviceUuid == uuidFrom("00001815-0000-1000-8000-00805f9b34fb") }
    .characteristics
    .first { it.characteristicUuid == uuidFrom("00002a56-0000-1000-8000-00805f9b34fb") }
    .descriptors
    .first { it.descriptorUuid == uuidFrom("00002902-0000-1000-8000-00805f9b34fb") }

Tip

This example uses a similar search algorithm as descriptorOf, but other search methods may be utilized. For example, properties of the characteristic could be queried to find a specific characteristic that is expected to be the parent of the sought after descriptor. When searching for a specific characteristic, descriptors can be read that may identity the sought after characteristic.

When connected, data can be read from, or written to, characteristics and/or descriptors via read and write functions.

val data = peripheral.read(characteristic)

peripheral.write(descriptor, byteArrayOf(1, 2, 3))

Note

The read and write functions throw NotConnectedException until a connection is established.

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

When used with characteristicOf, 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.

A Characteristic may also be obtained via the Peripheral.services property and used with the observe function. As before, if the connection drops, the Flow will remain active, upon reconnecting the same underlying platform characteristic will be used to to 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. An observationExceptionHandler may be registered with the Peripheral to control which failures are propagated through (and terminate) the observe Flow, for example:

Peripheral(advertisement) {
    observationExceptionHandler { cause ->
        // Log failure instead of propagating associated `observe` flow.
        println("Observation failure suppressed: $cause")
    }
}

In scenarios where an I/O operation needs to be performed upon subscribing to the observe Flow, an onSubscription 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).

Background Support

To enable background support on Apple, configure the CentralManager before using most of Kable's functionality:

CentralManager.configure {
    stateRestoration = true // `false` by default.
}

The CentralManager is initialized on first use (e.g. scanning or creating a peripheral), attempts to configure it after initialization will result in an IllegalStateException being thrown.

Setup

Android Permissions

Kable declares permissions for common use cases, but your app's configuration may need to be adjusted under the following conditions:

Your app... AndroidManifest.xml additions

Obtains the user's location (e.g. maps)

<uses-permission
    android:name="android.permission.ACCESS_COARSE_LOCATION"
    tools:node="replace"/>
<uses-permission
    android:name="android.permission.ACCESS_FINE_LOCATION"
    tools:node="replace"/>

Derives the user's location from Bluetooth Low Energy scans

<uses-permission
    android:name="android.permission.BLUETOOTH_SCAN"
    tools:node="replace"/>

Performs background Bluetooth Low Energy scans

<uses-permission
    android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
    android:maxSdkVersion="30"/>

Requires Bluetooth Low Energy (and won't function without it)

<uses-feature
    android:name="android.hardware.bluetooth_le"
    android:required="true"/>

Gradle

Maven Central

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 {
    androidTarget()
    js().browser()
    macosX64()
    iosX64()
    iosArm64()

    sourceSets {
        commonMain.dependencies {
            api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}")
            implementation("com.juul.kable:kable-core:${kableVersion}")
        }

        androidMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutinesVersion}")
        }
    }
}

android {
    // ...
}

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.