Skip to content

Commit

Permalink
Merge branch 'main' into twyatt/discovered-interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt committed Jan 27, 2025
2 parents d5529cb + cbbb30e commit e22873a
Show file tree
Hide file tree
Showing 21 changed files with 655 additions and 186 deletions.
91 changes: 76 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,51 @@ with Bluetooth Low Energy devices.

Usage is demonstrated with the [SensorTag sample app].

## UUIDs

UUIDs (Universally Unique Identifiers) are used to uniquely identify various components of a
Bluetooth Low Energy device. The Bluetooth Base UUID (`00000000-0000-1000-8000-00805F9B34FB`) allows
for short form (16-bit or 32-bit) UUIDs which are reserved for standard, predefined components
(e.g. 0x180D for "Heart Rate Service", or 0x2A37 for "Heart Rate Measurement").
128-bit UUIDs outside of the Bluetooth Base UUID are typically used for custom applications.

The `Bluetooth.BaseUuid` is provided to simplify defining 16-bit or 32-bit UUIDs. Simply add (`+`)
a 16-bit or 32-bit UUID (in [`Int`] or [`Long`] form) to the Bluetooth Base UUID to get a "full"
[`Uuid`] representation; for example:

```kotlin
val uuid16bit = 0x180D
val heartRateServiceUuid = Bluetooth.BaseUuid + uuid16bit
println(heartRateServiceUuid) // Output: 0000180d-0000-1000-8000-00805f9b34fb
```

Web Bluetooth named UUIDs may also be used to acquire [`Uuid`]s via the following [`Uuid`] extension
functions:

- `Uuid.service(name: String)`
- `Uuid.characteristic(name: String)`
- `Uuid.descriptor(name: String)`

For example:

```kotlin
val heartRateServiceUuid = Uuid.service("heart_rate")
println(heartRateServiceUuid) // Output: 0000180d-0000-1000-8000-00805f9b34fb
```

> [!NOTE]
> List of known UUID names can be found in [`Uuid.kt`](https://github.com/JuulLabs/kable/blob/main/kable-core/src/commonMain/kotlin/Uuid.kt).
Additional example shorthand notations:

| Shorthand | Canonical UUID |
|-----------------------------------|----------------------------------------|
| `Bluetooth.BaseUuid + 0x180D` | `0000180D-0000-1000-8000-00805F9B34FB` |
| `Bluetooth.BaseUuid + 0x12345678` | `12345678-0000-1000-8000-00805F9B34FB` |
| `Uuid.service("blood_pressure")` | `00001810-0000-1000-8000-00805F9B34FB` |
| `Uuid.characteristic("altitude")` | `00002AB3-0000-1000-8000-00805F9B34FB` |
| `Uuid.descriptor("valid_range")` | `00002906-0000-1000-8000-00805F9B34FB` |

## Scanning

To scan for nearby peripherals, the [`Scanner`] provides an [`advertisements`] [`Flow`] which is a stream of
Expand Down Expand Up @@ -68,7 +113,7 @@ To have peripherals D1 and D3 emitted during a scan, you could use the following
val scanner = Scanner {
filters {
match {
services = listOf(uuidFrom("0000aa80-0000-1000-8000-00805f9b34fb")) // SensorTag
services = listOf(Bluetooth.BaseUuid + 0xaa80) // SensorTag
}
match {
name = Filter.Name.Prefix("Ex")
Expand Down Expand Up @@ -316,8 +361,8 @@ val options = Options {
}
}
optionalServices = listOf(
uuidFrom("f000aa80-0451-4000-b000-000000000000"),
uuidFrom("f000aa81-0451-4000-b000-000000000000"),
Uuid.parse("f000aa80-0451-4000-b000-000000000000"),
Uuid.parse("f000aa81-0451-4000-b000-000000000000"),
)
}
val peripheral = requestPeripheral(options)
Expand Down Expand Up @@ -380,12 +425,12 @@ whereas characteristics and descriptors have the capability of being read from,

For example, a peripheral might have the following structure:

- Service S1 (`00001815-0000-1000-8000-00805f9b34fb`)
- Service S1 (`0x1815` or `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`)
- Characteristic C2 (`0x2a56` or `00002a56-0000-1000-8000-00805f9b34fb`)
- Descriptor D3 (`gatt.client_characteristic_configuration` or `00002902-0000-1000-8000-00805f9b34fb`)
- Service S2
- Characteristic C3

Expand All @@ -401,9 +446,9 @@ In the above example, to lazily access "Descriptor D3":

```kotlin
val descriptor = descriptorOf(
service = "00001815-0000-1000-8000-00805f9b34fb",
characteristic = "00002a56-0000-1000-8000-00805f9b34fb",
descriptor = "00002902-0000-1000-8000-00805f9b34fb"
service = Bluetooth.BaseUuid + 0x1815,
characteristic = Bluetooth.BaseUuid + 0x2A56,
descriptor = Uuid.descriptor("gatt.client_characteristic_configuration"),
)
```

Expand All @@ -417,18 +462,31 @@ To access "Descriptor D3" using a discovered descriptor:
```kotlin
val services = peripheral.services.value ?: error("Services have not been discovered")
val descriptor = services
.first { it.serviceUuid == uuidFrom("00001815-0000-1000-8000-00805f9b34fb") }
.first { it.serviceUuid == Uuid.parse("00001815-0000-1000-8000-00805f9b34fb") }
.characteristics
.first { it.characteristicUuid == uuidFrom("00002a56-0000-1000-8000-00805f9b34fb") }
.first { it.characteristicUuid == Uuid.parse("00002a56-0000-1000-8000-00805f9b34fb") }
.descriptors
.first { it.descriptorUuid == uuidFrom("00002902-0000-1000-8000-00805f9b34fb") }
.first { it.descriptorUuid == Uuid.parse("00002902-0000-1000-8000-00805f9b34fb") }
```

> [!TIP]
> _This example uses a similar search algorithm as `descriptorOf`, but other search methods may be utilized. For example,
> Shorthand notations are available for UUIDs. The accessing "Descriptor D3" example could be written as:
>
> ```kotlin
> val services = peripheral.services.value ?: error("Services have not been discovered")
> val descriptor = services
> .first { it.serviceUuid == Bluetooth.BaseUuid + 0x1815 }
> .characteristics
> .first { it.characteristicUuid == Bluetooth.BaseUuid + 0x2A56 }
> .descriptors
> .first { it.descriptorUuid == Uuid.descriptor("gatt.client_characteristic_configuration") }
> ```
> [!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._
> sought after characteristic.
When connected, data can be read from, or written to, characteristics and/or descriptors via [`read`] and [`write`]
functions.
Expand Down Expand Up @@ -667,19 +725,21 @@ limitations under the License.
[`Characteristic`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-characteristic/index.html
[`Connected`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-state/-connected/index.html
[`CoroutineScope.peripheral`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/peripheral.html
[`requestPeripheral`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/request-peripheral.html
[`CoroutineScope`]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/
[`Disconnected`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-state/-disconnected/index.html
[`Disconnecting`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-state/-disconnecting/index.html
[`Filter`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-filter/index.html
[`Flow`]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/
[`Int`]: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-int/
[`Long`]: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-long/
[`NotConnectedException`]: https://juullabs.github.io/kable/kable-exceptions/com.juul.kable/-not-connected-exception/index.html
[`Options`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-options/index.html
[`Peripheral.disconnect`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/disconnect.html
[`Peripheral.services`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/services.html
[`Peripheral`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/index.html
[`ScanSettings`]: https://developer.android.com/reference/kotlin/android/bluetooth/le/ScanSettings
[`Scanner`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-scanner.html
[`Uuid`]: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.uuid/-uuid/
[`WithoutResponse`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-write-type/-without-response/index.html
[`WriteType`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-write-type/index.html
[`advertisements`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-scanner/advertisements.html
Expand All @@ -692,6 +752,7 @@ limitations under the License.
[`observationExceptionHandler`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral-builder/observation-exception-handler.html
[`observe`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/observe.html
[`read`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/read.html
[`requestPeripheral`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/request-peripheral.html
[`state`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/state.html
[`writeWithoutResponse`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/write-without-response.html
[`write`]: https://juullabs.github.io/kable/kable-core/com.juul.kable/-peripheral/write.html
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version = "0.6.0" }
tuulbox-collections = { module = "com.juul.tuulbox:collections", version.ref = "tuulbox" }
tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tuulbox" }
wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "2025.1.3" }
wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "2025.1.6" }
wrappers-web = { module = "org.jetbrains.kotlin-wrappers:kotlin-web" }

[plugins]
android-library = { id = "com.android.library", version = "8.8.0" }
api = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.17.0" }
atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" }
dokka = { id = "org.jetbrains.dokka", version = "1.9.20" }
dokka = { id = "org.jetbrains.dokka", version = "2.0.0" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinter = { id = "org.jmailen.kotlinter", version = "5.0.1" }
maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" }
14 changes: 11 additions & 3 deletions kable-core/api/android/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ public class com/juul/kable/GattRequestRejectedException : java/lang/IllegalStat
}

public final class com/juul/kable/GattStatusException : java/io/IOException {
public fun <init> ()V
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;I)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getStatus ()I
}

public final class com/juul/kable/GattWriteException : com/juul/kable/GattRequestRejectedException {
Expand Down Expand Up @@ -377,7 +377,9 @@ public final class com/juul/kable/PooledThreadingStrategy : com/juul/kable/Threa

public final class com/juul/kable/ProfileKt {
public static final fun characteristicOf (Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Characteristic;
public static final fun characteristicOf (Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;)Lcom/juul/kable/Characteristic;
public static final fun descriptorOf (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Descriptor;
public static final fun descriptorOf (Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;)Lcom/juul/kable/Descriptor;
public static final fun getBroadcast-G25LNqA (I)Z
public static final fun getExtendedProperties-G25LNqA (I)Z
public static final fun getIndicate-G25LNqA (I)Z
Expand Down Expand Up @@ -571,6 +573,12 @@ public final class com/juul/kable/UnmetRequirementReason : java/lang/Enum {
public static fun values ()[Lcom/juul/kable/UnmetRequirementReason;
}

public final class com/juul/kable/UuidKt {
public static final fun characteristic (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
public static final fun descriptor (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
public static final fun service (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
}

public final class com/juul/kable/WriteType : java/lang/Enum {
public static final field WithResponse Lcom/juul/kable/WriteType;
public static final field WithoutResponse Lcom/juul/kable/WriteType;
Expand Down
18 changes: 15 additions & 3 deletions kable-core/api/jvm/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,14 @@ public final class com/juul/kable/FiltersBuilder {
public final fun match (Lkotlin/jvm/functions/Function1;)V
}

public final class com/juul/kable/GattStatusException : java/io/IOException {
public class com/juul/kable/GattRequestRejectedException : java/lang/IllegalStateException {
public fun <init> ()V
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
}

public final class com/juul/kable/GattStatusException : java/io/IOException {
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;I)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getStatus ()I
}

public final class com/juul/kable/IdentifierKt {
Expand Down Expand Up @@ -263,7 +267,9 @@ public abstract interface class com/juul/kable/PlatformAdvertisement : com/juul/

public final class com/juul/kable/ProfileKt {
public static final fun characteristicOf (Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Characteristic;
public static final fun characteristicOf (Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;)Lcom/juul/kable/Characteristic;
public static final fun descriptorOf (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Descriptor;
public static final fun descriptorOf (Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;Lkotlin/uuid/Uuid;)Lcom/juul/kable/Descriptor;
public static final fun getBroadcast-G25LNqA (I)Z
public static final fun getExtendedProperties-G25LNqA (I)Z
public static final fun getIndicate-G25LNqA (I)Z
Expand Down Expand Up @@ -416,6 +422,12 @@ public final class com/juul/kable/UnmetRequirementReason : java/lang/Enum {
public static fun values ()[Lcom/juul/kable/UnmetRequirementReason;
}

public final class com/juul/kable/UuidKt {
public static final fun characteristic (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
public static final fun descriptor (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
public static final fun service (Lkotlin/uuid/Uuid$Companion;Ljava/lang/String;)Lkotlin/uuid/Uuid;
}

public final class com/juul/kable/WriteType : java/lang/Enum {
public static final field WithResponse Lcom/juul/kable/WriteType;
public static final field WithoutResponse Lcom/juul/kable/WriteType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.uuid.toKotlinUuid

// Number of service discovery attempts to make if no services are discovered.
// https://github.com/JuulLabs/kable/issues/295
Expand Down Expand Up @@ -370,7 +369,7 @@ private val Priority.intValue: Int
}

private val PlatformCharacteristic.configDescriptor: PlatformDescriptor?
get() = descriptors.firstOrNull { clientCharacteristicConfigUuid == it.uuid.toKotlinUuid() }
get() = descriptors.firstOrNull { clientCharacteristicConfigUuid == it.uuid }

private val PlatformCharacteristic.supportsNotify: Boolean
get() = properties and PROPERTY_NOTIFY != 0
Expand Down
6 changes: 4 additions & 2 deletions kable-core/src/androidMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.os.Handler
import com.juul.kable.State.Disconnected
import com.juul.kable.android.GattStatus
import com.juul.kable.coroutines.childSupervisor
import com.juul.kable.gatt.Callback
import com.juul.kable.gatt.GattStatus
import com.juul.kable.gatt.Response
import com.juul.kable.gatt.Response.OnServicesDiscovered
import com.juul.kable.logs.Logger
Expand Down Expand Up @@ -272,5 +272,7 @@ internal class Connection(
}

private fun checkResponse(response: Response) {
if (response.status != GattSuccess) throw GattStatusException(response.toString())
if (response.status != GattSuccess) {
throw GattStatusException(response.toString(), status = response.status.value)
}
}
16 changes: 0 additions & 16 deletions kable-core/src/androidMain/kotlin/Exceptions.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
package com.juul.kable

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.os.RemoteException
import com.juul.kable.AndroidPeripheral.WriteResult

/**
* Thrown when underlying [BluetoothGatt] method call returns `false`. This can occur under the
* following conditions:
*
* - Request isn't allowed (e.g. reading a non-readable characteristic)
* - Underlying service or client interface is missing or invalid (e.g. `mService == null || mClientIf == 0`)
* - Associated [BluetoothDevice] is unavailable
* - Device is busy (i.e. a previous request is still in-progress)
* - An Android internal failure occurred (i.e. an underlying [RemoteException] was thrown)
*/
public open class GattRequestRejectedException internal constructor(
message: String? = null,
) : IllegalStateException(message)

/**
* Thrown when underlying [BluetoothGatt] write operation call fails.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.juul.kable.bluetooth

import com.juul.kable.external.CLIENT_CHARACTERISTIC_CONFIG_UUID
import kotlin.uuid.Uuid
import com.juul.kable.Bluetooth
import kotlin.uuid.toJavaUuid

internal val clientCharacteristicConfigUuid = Uuid.parse(CLIENT_CHARACTERISTIC_CONFIG_UUID)
internal val clientCharacteristicConfigUuid = (Bluetooth.BaseUuid + 0x2902).toJavaUuid()
34 changes: 0 additions & 34 deletions kable-core/src/androidMain/kotlin/external/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,3 @@ internal const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOS
internal const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT
internal const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT
internal const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL

// 0xE0 ~ 0xFC reserved for future use
// https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/lollipop-release/stack/include/gatt_api.h#27
internal const val GATT_INVALID_HANDLE = 0x01
internal const val GATT_INVALID_PDU = 0x04
internal const val GATT_INSUF_AUTHORIZATION = 0x08
internal const val GATT_PREPARE_Q_FULL = 0x09
internal const val GATT_NOT_FOUND = 0x0a
internal const val GATT_NOT_LONG = 0x0b
internal const val GATT_INSUF_KEY_SIZE = 0x0c
internal const val GATT_ERR_UNLIKELY = 0x0e
internal const val GATT_UNSUPPORT_GRP_TYPE = 0x10
internal const val GATT_INSUF_RESOURCE = 0x11
internal const val GATT_ILLEGAL_PARAMETER = 0x87
internal const val GATT_NO_RESOURCES = 0x80
internal const val GATT_INTERNAL_ERROR = 0x81
internal const val GATT_WRONG_STATE = 0x82
internal const val GATT_DB_FULL = 0x83
internal const val GATT_BUSY = 0x84
internal const val GATT_ERROR = 0x85
internal const val GATT_CMD_STARTED = 0x86
internal const val GATT_PENDING = 0x88
internal const val GATT_AUTH_FAIL = 0x89
internal const val GATT_MORE = 0x8a
internal const val GATT_INVALID_CFG = 0x8b
internal const val GATT_SERVICE_STARTED = 0x8c
internal const val GATT_ENCRYPED_NO_MITM = 0x8d
internal const val GATT_NOT_ENCRYPTED = 0x8e
internal const val GATT_CCC_CFG_ERR = 0xFD
internal const val GATT_PRC_IN_PROGRESS = 0xFE
internal const val GATT_OUT_OF_RANGE = 0xFF

// https://android.googlesource.com/platform/development/+/7167a054a8027f75025c865322fa84791a9b3bd1/samples/BluetoothLeGatt/src/com/example/bluetooth/le/SampleGattAttributes.java#27
internal const val CLIENT_CHARACTERISTIC_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb"
Loading

0 comments on commit e22873a

Please sign in to comment.