From 3fb27e6f841a9585e06335ac59ec3539ef03c26a Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Wed, 5 Jan 2022 02:31:29 -0800 Subject: [PATCH] Make `services` direct references and match properties for I/O Previously, all I/O operations would lazily search for the first match when looking up characteristics or descriptors. This was problematic if duplicate UUIDs existed. Discovered GATT profile items (services, characteristics and descriptors) as provided via `Peripheral.services` are now direct references to underlying platform types. This means they no longer trigger lookups for I/O operations and specific GATT profile items can be sought after and used. When using `characteristicOf`, the properties of the characteristic are now taken into account when searching for the underlying platform object. For example, when performing a read, only a characteristic with a matching UUID **and** `read` property will be considered a match. --- .gitignore | 1 + README.md | 38 +++- core/api/core.api | 57 ++++-- core/src/androidMain/kotlin/Peripheral.kt | 85 ++++----- .../kotlin/PlatformCharacteristic.kt | 36 ---- .../androidMain/kotlin/PlatformDescriptor.kt | 28 --- .../src/androidMain/kotlin/PlatformService.kt | 67 ------- core/src/androidMain/kotlin/Profile.kt | 54 ++++++ core/src/appleMain/kotlin/Peripheral.kt | 54 +++--- .../kotlin/PlatformCharacteristic.kt | 40 ---- .../appleMain/kotlin/PlatformDescriptor.kt | 27 --- core/src/appleMain/kotlin/PlatformService.kt | 69 ------- core/src/appleMain/kotlin/Profile.kt | 57 ++++++ core/src/commonMain/kotlin/Characteristic.kt | 33 ---- core/src/commonMain/kotlin/Descriptor.kt | 23 --- core/src/commonMain/kotlin/Peripheral.kt | 56 +++++- core/src/commonMain/kotlin/Profile.kt | 176 ++++++++++++++++++ core/src/commonMain/kotlin/Service.kt | 18 -- core/src/jsMain/kotlin/Peripheral.kt | 43 ++--- .../jsMain/kotlin/PlatformCharacteristic.kt | 45 ----- core/src/jsMain/kotlin/PlatformDescriptor.kt | 27 --- core/src/jsMain/kotlin/PlatformService.kt | 71 ------- core/src/jsMain/kotlin/Profile.kt | 93 +++++++++ .../BluetoothCharacteristicProperties.kt | 35 ++++ .../BluetoothRemoteGATTCharacteristic.kt | 1 + .../external/BluetoothRemoteGATTDescriptor.kt | 1 + 26 files changed, 623 insertions(+), 612 deletions(-) delete mode 100644 core/src/androidMain/kotlin/PlatformCharacteristic.kt delete mode 100644 core/src/androidMain/kotlin/PlatformDescriptor.kt delete mode 100644 core/src/androidMain/kotlin/PlatformService.kt create mode 100644 core/src/androidMain/kotlin/Profile.kt delete mode 100644 core/src/appleMain/kotlin/PlatformCharacteristic.kt delete mode 100644 core/src/appleMain/kotlin/PlatformDescriptor.kt delete mode 100644 core/src/appleMain/kotlin/PlatformService.kt create mode 100644 core/src/appleMain/kotlin/Profile.kt delete mode 100644 core/src/commonMain/kotlin/Characteristic.kt create mode 100644 core/src/commonMain/kotlin/Profile.kt delete mode 100644 core/src/commonMain/kotlin/Service.kt delete mode 100644 core/src/jsMain/kotlin/PlatformCharacteristic.kt delete mode 100644 core/src/jsMain/kotlin/PlatformDescriptor.kt delete mode 100644 core/src/jsMain/kotlin/PlatformService.kt create mode 100644 core/src/jsMain/kotlin/Profile.kt create mode 100644 core/src/jsMain/kotlin/external/BluetoothCharacteristicProperties.kt diff --git a/.gitignore b/.gitignore index 9e83621d7..919463c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ .DS_Store local.properties +kotlin-js-store/ diff --git a/README.md b/README.md index 2e1fddbb6..aad9d7383 100644 --- a/README.md +++ b/README.md @@ -299,9 +299,15 @@ For example, a peripheral might have the following structure: - Service S2 - Characteristic C3 -To access a characteristic or descriptor, use the [`charactisticOf`] or [`descriptorOf`] functions, respectively. +To access a characteristic or descriptor, use the [`charactisticOf`] or [`descriptorOf`] functions, respectively. These +functions lazily search for the first match (based on UUID) in the GATT profile when performing I/O. -In the above example, to access "Descriptor D3": +_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 [`WithResponse`], the first characteristic matching the expected UUID **and** having the +[`writeWithResponse`] property will be used._ + +In the above example, to lazily access "Descriptor D3": ```kotlin val descriptor = descriptorOf( @@ -311,7 +317,29 @@ val descriptor = descriptorOf( ) ``` -Once connected, data can be read from, or written to, characteristics and/or descriptors via [`read`] and [`write`] +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: + +```kotlin +val services = peripheral.services ?: 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") } +``` + +_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. _The [`read`] and [`write`] functions throw [`NotReadyException`] until a connection is established._ @@ -533,12 +561,16 @@ limitations under the License. [`Options`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-options/index.html [`Peripheral`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-peripheral/index.html [`Peripheral.disconnect`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-peripheral/index.html#%5Bcom.juul.kable%2FPeripheral%2Fdisconnect%2F%23%2FPointingToDeclaration%2F%5D%2FFunctions%2F-328684452 +[`Peripheral.services`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/index.html#-1607712299%2FProperties%2F-2011752812 [`read`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-peripheral/index.html#%5Bcom.juul.kable%2FPeripheral%2Fread%2F%23com.juul.kable.Characteristic%2FPointingToDeclaration%2F%2C+com.juul.kable%2FPeripheral%2Fread%2F%23com.juul.kable.Descriptor%2FPointingToDeclaration%2F%5D%2FFunctions%2F-328684452 [`requestPeripheral`]: https://juullabs.github.io/kable/core/core/com.juul.kable/request-peripheral.html [`Scanner`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-scanner/index.html [`state`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-peripheral/index.html#%5Bcom.juul.kable%2FPeripheral%2Fstate%2F%23%2FPointingToDeclaration%2F%5D%2FProperties%2F-328684452 [connection-state]: https://juullabs.github.io/kable/core/core/com.juul.kable/-state/index.html [`write`]: https://juullabs.github.io/kable/core/core/com.juul.kable/-peripheral/index.html#%5Bcom.juul.kable%2FPeripheral%2Fwrite%2F%23com.juul.kable.Descriptor%23kotlin.ByteArray%2FPointingToDeclaration%2F%2C+com.juul.kable%2FPeripheral%2Fwrite%2F%23com.juul.kable.Characteristic%23kotlin.ByteArray%23com.juul.kable.WriteType%2FPointingToDeclaration%2F%5D%2FFunctions%2F-328684452 +[`WriteType`]: https://juullabs.github.io/kable/core/com.juul.kable/-write-type/index.html +[`WithResponse`]: https://juullabs.github.io/kable/core/com.juul.kable/-write-type/index.html#-1405019860%2FClasslikes%2F-2011752812 +[`writeWithResponse`]: https://juullabs.github.io/kable/core/com.juul.kable/-characteristic/-properties/index.html#491699083%2FExtensions%2F-2011752812 [badge-android]: http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat [badge-ios]: http://img.shields.io/badge/platform-ios-CDCDCD.svg?style=flat diff --git a/core/api/core.api b/core/api/core.api index 8101f3314..074452099 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -54,8 +54,17 @@ public abstract interface class com/juul/kable/Characteristic { public abstract fun getServiceUuid ()Ljava/util/UUID; } -public final class com/juul/kable/CharacteristicKt { - public static final fun characteristicOf (Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Characteristic; +public final class com/juul/kable/Characteristic$Properties { + public static final synthetic fun box-impl (I)Lcom/juul/kable/Characteristic$Properties; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (ILjava/lang/Object;)Z + public static final fun equals-impl0 (II)Z + public final fun getValue ()I + public fun hashCode ()I + public static fun hashCode-impl (I)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (I)Ljava/lang/String; + public final synthetic fun unbox-impl ()I } public final class com/juul/kable/ConnectionLostException : java/io/IOException { @@ -72,31 +81,36 @@ public abstract interface class com/juul/kable/Descriptor { public abstract fun getServiceUuid ()Ljava/util/UUID; } -public final class com/juul/kable/DescriptorKt { - public static final fun descriptorOf (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Descriptor; -} - public final class com/juul/kable/DiscoveredCharacteristic : com/juul/kable/Characteristic { - public final fun component1 ()Ljava/util/UUID; - public final fun component2 ()Ljava/util/UUID; - public final fun component3 ()Ljava/util/List; - public final fun copy (Ljava/util/UUID;Ljava/util/UUID;Ljava/util/List;)Lcom/juul/kable/DiscoveredCharacteristic; - public static synthetic fun copy$default (Lcom/juul/kable/DiscoveredCharacteristic;Ljava/util/UUID;Ljava/util/UUID;Ljava/util/List;ILjava/lang/Object;)Lcom/juul/kable/DiscoveredCharacteristic; + public final fun copy (Landroid/bluetooth/BluetoothGattCharacteristic;)Lcom/juul/kable/DiscoveredCharacteristic; + public static synthetic fun copy$default (Lcom/juul/kable/DiscoveredCharacteristic;Landroid/bluetooth/BluetoothGattCharacteristic;ILjava/lang/Object;)Lcom/juul/kable/DiscoveredCharacteristic; public fun equals (Ljava/lang/Object;)Z public fun getCharacteristicUuid ()Ljava/util/UUID; public final fun getDescriptors ()Ljava/util/List; + public final fun getInstanceId ()I + public final fun getProperties-bty6q6U ()I + public fun getServiceUuid ()Ljava/util/UUID; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/juul/kable/DiscoveredDescriptor : com/juul/kable/Descriptor { + public final fun copy (Landroid/bluetooth/BluetoothGattDescriptor;)Lcom/juul/kable/DiscoveredDescriptor; + public static synthetic fun copy$default (Lcom/juul/kable/DiscoveredDescriptor;Landroid/bluetooth/BluetoothGattDescriptor;ILjava/lang/Object;)Lcom/juul/kable/DiscoveredDescriptor; + public fun equals (Ljava/lang/Object;)Z + public fun getCharacteristicUuid ()Ljava/util/UUID; + public fun getDescriptorUuid ()Ljava/util/UUID; public fun getServiceUuid ()Ljava/util/UUID; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/juul/kable/DiscoveredService : com/juul/kable/Service { - public final fun component1 ()Ljava/util/UUID; - public final fun component2 ()Ljava/util/List; - public final fun copy (Ljava/util/UUID;Ljava/util/List;)Lcom/juul/kable/DiscoveredService; - public static synthetic fun copy$default (Lcom/juul/kable/DiscoveredService;Ljava/util/UUID;Ljava/util/List;ILjava/lang/Object;)Lcom/juul/kable/DiscoveredService; + public final fun copy (Landroid/bluetooth/BluetoothGattService;)Lcom/juul/kable/DiscoveredService; + public static synthetic fun copy$default (Lcom/juul/kable/DiscoveredService;Landroid/bluetooth/BluetoothGattService;ILjava/lang/Object;)Lcom/juul/kable/DiscoveredService; public fun equals (Ljava/lang/Object;)Z public final fun getCharacteristics ()Ljava/util/List; + public final fun getInstanceId ()I public fun getServiceUuid ()Ljava/util/UUID; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -235,6 +249,19 @@ public final class com/juul/kable/Priority : java/lang/Enum { public static fun values ()[Lcom/juul/kable/Priority; } +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 descriptorOf (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)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 + public static final fun getNotify-G25LNqA (I)Z + public static final fun getRead-G25LNqA (I)Z + public static final fun getSignedWrite-G25LNqA (I)Z + public static final fun getWrite-G25LNqA (I)Z + public static final fun getWriteWithoutResponse-G25LNqA (I)Z +} + public final class com/juul/kable/ScanFailedException : java/lang/IllegalStateException { public final fun getErrorCode ()I } diff --git a/core/src/androidMain/kotlin/Peripheral.kt b/core/src/androidMain/kotlin/Peripheral.kt index b542da1f6..9133c806a 100644 --- a/core/src/androidMain/kotlin/Peripheral.kt +++ b/core/src/androidMain/kotlin/Peripheral.kt @@ -11,7 +11,6 @@ import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE -import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE @@ -169,12 +168,13 @@ public class AndroidPeripheral internal constructor( private val observers = Observers(this, logging) @Volatile - private var _platformServices: List? = null - private val platformServices: List - get() = checkNotNull(_platformServices) { "Services have not been discovered for $this" } + private var _discoveredServices: List? = null + private val discoveredServices: List + get() = _discoveredServices + ?: throw IllegalStateException("Services have not been discovered for $this") public override val services: List? - get() = _platformServices?.map { it.toDiscoveredService() } + get() = _discoveredServices?.toList() @Volatile private var _connection: Connection? = null @@ -264,9 +264,9 @@ public class AndroidPeripheral internal constructor( connection.execute { discoverServices() } - _platformServices = withContext(connection.dispatcher) { - connection.bluetoothGatt.services - }.map { it.toPlatformService() } + _discoveredServices = withContext(connection.dispatcher) { + connection.bluetoothGatt.services.map(::DiscoveredService) + } } /** @@ -298,11 +298,11 @@ public class AndroidPeripheral internal constructor( detail(data) } - val bluetoothGattCharacteristic = bluetoothGattCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) connection.execute { - bluetoothGattCharacteristic.value = data - bluetoothGattCharacteristic.writeType = writeType.intValue - writeCharacteristic(bluetoothGattCharacteristic) + platformCharacteristic.value = data + platformCharacteristic.writeType = writeType.intValue + writeCharacteristic(platformCharacteristic) } } @@ -314,9 +314,9 @@ public class AndroidPeripheral internal constructor( detail(characteristic) } - val bluetoothGattCharacteristic = bluetoothGattCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Read) return connection.execute { - readCharacteristic(bluetoothGattCharacteristic) + readCharacteristic(platformCharacteristic) }.value!! } @@ -324,27 +324,22 @@ public class AndroidPeripheral internal constructor( descriptor: Descriptor, data: ByteArray, ) { - logger.debug { - message = "write" - detail(descriptor) - detail(data) - } - write(bluetoothGattDescriptorFrom(descriptor), data) + write(discoveredServices.obtain(descriptor), data) } private suspend fun write( - bluetoothGattDescriptor: BluetoothGattDescriptor, + platformDescriptor: PlatformDescriptor, data: ByteArray, ) { logger.debug { message = "write" - detail(bluetoothGattDescriptor) + detail(platformDescriptor) detail(data) } connection.execute { - bluetoothGattDescriptor.value = data - writeDescriptor(bluetoothGattDescriptor) + platformDescriptor.value = data + writeDescriptor(platformDescriptor) } } @@ -355,9 +350,10 @@ public class AndroidPeripheral internal constructor( message = "read" detail(descriptor) } - val bluetoothGattDescriptor = bluetoothGattDescriptorFrom(descriptor) + + val platformDescriptor = discoveredServices.obtain(descriptor) return connection.execute { - readDescriptor(bluetoothGattDescriptor) + readDescriptor(platformDescriptor) }.value!! } @@ -367,12 +363,13 @@ public class AndroidPeripheral internal constructor( ): Flow = observers.acquire(characteristic, onSubscription) internal suspend fun startObservation(characteristic: Characteristic) { - val platformCharacteristic = platformServices.findCharacteristic(characteristic) logger.debug { message = "setCharacteristicNotification" detail(characteristic) detail("value", "true") } + + val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) connection .bluetoothGatt .setCharacteristicNotification(platformCharacteristic, true) @@ -380,7 +377,7 @@ public class AndroidPeripheral internal constructor( } internal suspend fun stopObservation(characteristic: Characteristic) { - val platformCharacteristic = platformServices.findCharacteristic(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) try { setConfigDescriptor(platformCharacteristic, enable = false) @@ -402,23 +399,21 @@ public class AndroidPeripheral internal constructor( ) { val configDescriptor = characteristic.configDescriptor if (configDescriptor != null) { - val bluetoothGattDescriptor = configDescriptor.bluetoothGattDescriptor - if (enable) { when { characteristic.supportsNotify -> { logger.verbose { message = "Writing ENABLE_NOTIFICATION_VALUE to CCCD" - detail(bluetoothGattDescriptor) + detail(configDescriptor) } - write(bluetoothGattDescriptor, ENABLE_NOTIFICATION_VALUE) + write(configDescriptor, ENABLE_NOTIFICATION_VALUE) } characteristic.supportsIndicate -> { logger.verbose { message = "Writing ENABLE_INDICATION_VALUE to CCCD" - detail(bluetoothGattDescriptor) + detail(configDescriptor) } - write(bluetoothGattDescriptor, ENABLE_INDICATION_VALUE) + write(configDescriptor, ENABLE_INDICATION_VALUE) } else -> logger.warn { message = "Characteristic supports neither notification nor indication" @@ -429,9 +424,9 @@ public class AndroidPeripheral internal constructor( if (characteristic.supportsNotify || characteristic.supportsIndicate) { logger.verbose { message = "Writing DISABLE_NOTIFICATION_VALUE to CCCD" - detail(bluetoothGattDescriptor) + detail(configDescriptor) } - write(bluetoothGattDescriptor, DISABLE_NOTIFICATION_VALUE) + write(configDescriptor, DISABLE_NOTIFICATION_VALUE) } } } else { @@ -442,14 +437,6 @@ public class AndroidPeripheral internal constructor( } } - private fun bluetoothGattCharacteristicFrom( - characteristic: Characteristic - ) = platformServices.findCharacteristic(characteristic).bluetoothGattCharacteristic - - private fun bluetoothGattDescriptorFrom( - descriptor: Descriptor - ) = platformServices.findDescriptor(descriptor).bluetoothGattDescriptor - override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)" } @@ -467,22 +454,22 @@ private val Priority.intValue: Int } /** @throws GattRequestRejectedException if [BluetoothGatt.setCharacteristicNotification] returns `false`. */ -private fun BluetoothGatt.setCharacteristicNotification( +private fun BluetoothGatt.setCharacteristicNotificationOrThrow( characteristic: PlatformCharacteristic, enable: Boolean, ) { - setCharacteristicNotification(characteristic.bluetoothGattCharacteristic, enable) || + setCharacteristicNotification(characteristic, enable) || throw GattRequestRejectedException() } private val PlatformCharacteristic.configDescriptor: PlatformDescriptor? - get() = descriptors.firstOrNull(clientCharacteristicConfigUuid) + get() = descriptors.firstOrNull { clientCharacteristicConfigUuid == it.uuid } private val PlatformCharacteristic.supportsNotify: Boolean - get() = bluetoothGattCharacteristic.properties and PROPERTY_NOTIFY != 0 + get() = properties and PROPERTY_NOTIFY != 0 private val PlatformCharacteristic.supportsIndicate: Boolean - get() = bluetoothGattCharacteristic.properties and PROPERTY_INDICATE != 0 + get() = properties and PROPERTY_INDICATE != 0 /** * Explicitly check the adapter state before connecting in order to respect system settings. diff --git a/core/src/androidMain/kotlin/PlatformCharacteristic.kt b/core/src/androidMain/kotlin/PlatformCharacteristic.kt deleted file mode 100644 index 192d75f8d..000000000 --- a/core/src/androidMain/kotlin/PlatformCharacteristic.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.juul.kable - -import android.bluetooth.BluetoothGattCharacteristic -import com.benasher44.uuid.Uuid - -@Suppress("PROPERTY_TYPE_MISMATCH_ON_OVERRIDE") // https://youtrack.jetbrains.com/issue/KTIJ-405 -internal data class PlatformCharacteristic( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - val bluetoothGattCharacteristic: BluetoothGattCharacteristic, - val descriptors: List, -) : Characteristic - -internal fun PlatformCharacteristic.toDiscoveredCharacteristic() = DiscoveredCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptors = descriptors.map { it.toLazyDescriptor() }, -) - -internal fun BluetoothGattCharacteristic.toPlatformCharacteristic(): PlatformCharacteristic { - val platformDescriptors = descriptors.map { descriptor -> - descriptor.toPlatformDescriptor(service.uuid, uuid) - } - - return PlatformCharacteristic( - serviceUuid = service.uuid, - characteristicUuid = uuid, - descriptors = platformDescriptors, - bluetoothGattCharacteristic = this, - ) -} - -internal fun BluetoothGattCharacteristic.toLazyCharacteristic() = LazyCharacteristic( - serviceUuid = service.uuid, - characteristicUuid = uuid, -) diff --git a/core/src/androidMain/kotlin/PlatformDescriptor.kt b/core/src/androidMain/kotlin/PlatformDescriptor.kt deleted file mode 100644 index c54c3963d..000000000 --- a/core/src/androidMain/kotlin/PlatformDescriptor.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.juul.kable - -import android.bluetooth.BluetoothGattDescriptor -import com.benasher44.uuid.Uuid - -@Suppress("PROPERTY_TYPE_MISMATCH_ON_OVERRIDE") // https://youtrack.jetbrains.com/issue/KTIJ-405 -internal data class PlatformDescriptor( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - override val descriptorUuid: Uuid, - val bluetoothGattDescriptor: BluetoothGattDescriptor, -) : Descriptor - -internal fun PlatformDescriptor.toLazyDescriptor() = LazyDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = descriptorUuid, -) - -internal fun BluetoothGattDescriptor.toPlatformDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, -) = PlatformDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = uuid, - bluetoothGattDescriptor = this, -) diff --git a/core/src/androidMain/kotlin/PlatformService.kt b/core/src/androidMain/kotlin/PlatformService.kt deleted file mode 100644 index 0c8c23f9f..000000000 --- a/core/src/androidMain/kotlin/PlatformService.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.juul.kable - -import android.bluetooth.BluetoothGattService -import com.benasher44.uuid.Uuid - -@Suppress("PROPERTY_TYPE_MISMATCH_ON_OVERRIDE") // https://youtrack.jetbrains.com/issue/KTIJ-405 -internal data class PlatformService( - override val serviceUuid: Uuid, - val bluetoothGattService: BluetoothGattService, - val characteristics: List, -) : Service - -internal fun PlatformService.toDiscoveredService() = DiscoveredService( - serviceUuid = serviceUuid, - characteristics = characteristics.map { it.toDiscoveredCharacteristic() }, -) - -internal fun BluetoothGattService.toPlatformService(): PlatformService { - val serviceUuid = uuid - val characteristics = characteristics - .map { characteristic -> characteristic.toPlatformCharacteristic() } - - return PlatformService( - serviceUuid = serviceUuid, - characteristics = characteristics, - bluetoothGattService = this, - ) -} - -/** @throws NoSuchElementException if service or characteristic is not found. */ -internal fun List.findCharacteristic( - characteristic: Characteristic -): PlatformCharacteristic = - findCharacteristic( - serviceUuid = characteristic.serviceUuid, - characteristicUuid = characteristic.characteristicUuid - ) - -/** @throws NoSuchElementException if service or characteristic is not found. */ -private fun List.findCharacteristic( - serviceUuid: Uuid, - characteristicUuid: Uuid -): PlatformCharacteristic = - first(serviceUuid) - .characteristics - .first(characteristicUuid) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -internal fun List.findDescriptor( - descriptor: Descriptor -): PlatformDescriptor = - findDescriptor( - serviceUuid = descriptor.serviceUuid, - characteristicUuid = descriptor.characteristicUuid, - descriptorUuid = descriptor.descriptorUuid - ) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -private fun List.findDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, - descriptorUuid: Uuid -): PlatformDescriptor = - findCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid - ).descriptors.first(descriptorUuid) diff --git a/core/src/androidMain/kotlin/Profile.kt b/core/src/androidMain/kotlin/Profile.kt new file mode 100644 index 000000000..44adcbb18 --- /dev/null +++ b/core/src/androidMain/kotlin/Profile.kt @@ -0,0 +1,54 @@ +@file:JvmName("ProfileAndroid") + +package com.juul.kable + +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import com.benasher44.uuid.Uuid +import com.juul.kable.Characteristic.Properties + +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformService = BluetoothGattService +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformCharacteristic = BluetoothGattCharacteristic +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformDescriptor = BluetoothGattDescriptor + +public actual data class DiscoveredService internal constructor( + internal actual val service: PlatformService, +) : Service { + + public actual val characteristics: List = + service.characteristics.map(::DiscoveredCharacteristic) + + override val serviceUuid: Uuid get() = service.uuid + val instanceId: Int get() = service.instanceId +} + +public actual data class DiscoveredCharacteristic internal constructor( + internal actual val characteristic: PlatformCharacteristic, +) : Characteristic { + + public actual val descriptors: List = + characteristic.descriptors.map(::DiscoveredDescriptor) + + override val serviceUuid: Uuid get() = characteristic.service.uuid + override val characteristicUuid: Uuid get() = characteristic.uuid + val instanceId: Int get() = characteristic.instanceId + actual val properties: Properties get() = Properties(characteristic.properties) +} + +public actual data class DiscoveredDescriptor internal constructor( + internal actual val descriptor: PlatformDescriptor, +) : Descriptor { + + override val serviceUuid: Uuid get() = descriptor.characteristic.service.uuid + override val characteristicUuid: Uuid get() = descriptor.characteristic.uuid + override val descriptorUuid: Uuid get() = descriptor.uuid +} + +internal fun PlatformCharacteristic.toLazyCharacteristic() = LazyCharacteristic( + serviceUuid = service.uuid, + characteristicUuid = uuid, +) diff --git a/core/src/appleMain/kotlin/Peripheral.kt b/core/src/appleMain/kotlin/Peripheral.kt index bd29c3278..f9fc34ea0 100644 --- a/core/src/appleMain/kotlin/Peripheral.kt +++ b/core/src/appleMain/kotlin/Peripheral.kt @@ -119,14 +119,13 @@ public class ApplePeripheral internal constructor( private val observers = Observers(this, logger) - private val _platformServices = atomic?>(null) - private val platformServices: List - get() = checkNotNull(_platformServices.value) { - "Services have not been discovered for $this" - } + private val _discoveredServices = atomic?>(null) + private val discoveredServices: List + get() = _discoveredServices.value + ?: throw IllegalStateException("Services have not been discovered for $this") public override val services: List? - get() = _platformServices.value?.map { it.toDiscoveredService() } + get() = _discoveredServices.value?.toList() private val _connection = atomic(null) private val connection: Connection @@ -233,9 +232,10 @@ public class ApplePeripheral internal constructor( } } - _platformServices.value = cbPeripheral.services?.map { service -> - (service as CBService).toPlatformService() - } + _discoveredServices.value = cbPeripheral.services + .orEmpty() + .map { it as PlatformService } + .map(::DiscoveredService) } @Throws(CancellationException::class, IOException::class, NotReadyException::class) @@ -258,9 +258,9 @@ public class ApplePeripheral internal constructor( detail(data) } - val cbCharacteristic = cbCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) connection.execute { - centralManager.write(cbPeripheral, data, cbCharacteristic, writeType.cbWriteType) + centralManager.write(cbPeripheral, data, platformCharacteristic, writeType.cbWriteType) } } @@ -279,14 +279,14 @@ public class ApplePeripheral internal constructor( } val connection = this.connection - val cbCharacteristic = cbCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Read) return connection.semaphore.withPermit { connection .delegate .characteristicChanges - .onSubscription { centralManager.read(cbPeripheral, cbCharacteristic) } - .firstOrThrow { it.cbCharacteristic.UUID == cbCharacteristic.UUID } + .onSubscription { centralManager.read(cbPeripheral, platformCharacteristic) } + .firstOrThrow { it.cbCharacteristic.UUID == platformCharacteristic.UUID } } } @@ -307,9 +307,9 @@ public class ApplePeripheral internal constructor( detail(data) } - val cbDescriptor = cbDescriptorFrom(descriptor) + val platformDescriptor = discoveredServices.obtain(descriptor) connection.execute { - centralManager.write(cbPeripheral, data, cbDescriptor) + centralManager.write(cbPeripheral, data, platformDescriptor) } } @@ -327,16 +327,16 @@ public class ApplePeripheral internal constructor( detail(descriptor) } - val cbDescriptor = cbDescriptorFrom(descriptor) + val platformDescriptor = discoveredServices.obtain(descriptor) return connection.execute { - centralManager.read(cbPeripheral, cbDescriptor) + centralManager.read(cbPeripheral, platformDescriptor) }.descriptor.value as NSData } public override fun observe( characteristic: Characteristic, onSubscription: OnSubscriptionAction, - ): Flow = observeAsNSData(characteristic, onSubscription).map { it.toByteArray() } + ): Flow = observeAsNSData(characteristic, onSubscription).map(NSData::toByteArray) public fun observeAsNSData( characteristic: Characteristic, @@ -349,9 +349,9 @@ public class ApplePeripheral internal constructor( detail(characteristic) } - val cbCharacteristic = cbCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) connection.execute { - centralManager.notify(cbPeripheral, cbCharacteristic) + centralManager.notify(cbPeripheral, platformCharacteristic) } } @@ -361,20 +361,12 @@ public class ApplePeripheral internal constructor( detail(characteristic) } - val cbCharacteristic = cbCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) connection.execute { - centralManager.cancelNotify(cbPeripheral, cbCharacteristic) + centralManager.cancelNotify(cbPeripheral, platformCharacteristic) } } - private fun cbCharacteristicFrom( - characteristic: Characteristic, - ) = platformServices.findCharacteristic(characteristic).cbCharacteristic - - private fun cbDescriptorFrom( - descriptor: Descriptor, - ) = platformServices.findDescriptor(descriptor).cbDescriptor - override fun toString(): String = "Peripheral(cbPeripheral=$cbPeripheral)" } diff --git a/core/src/appleMain/kotlin/PlatformCharacteristic.kt b/core/src/appleMain/kotlin/PlatformCharacteristic.kt deleted file mode 100644 index 8a14923b6..000000000 --- a/core/src/appleMain/kotlin/PlatformCharacteristic.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import platform.CoreBluetooth.CBCharacteristic -import platform.CoreBluetooth.CBDescriptor - -internal data class PlatformCharacteristic( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - val cbCharacteristic: CBCharacteristic, - val descriptors: List, -) : Characteristic - -internal fun PlatformCharacteristic.toDiscoveredCharacteristic() = DiscoveredCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptors = descriptors.map { it.toLazyDescriptor() }, -) - -internal fun CBCharacteristic.toPlatformCharacteristic( - serviceUuid: Uuid, -): PlatformCharacteristic { - val characteristicUuid = UUID.toUuid() - val platformDescriptors = descriptors?.map { descriptor -> - descriptor as CBDescriptor - descriptor.toPlatformDescriptor(serviceUuid, characteristicUuid) - } ?: emptyList() - - return PlatformCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptors = platformDescriptors, - cbCharacteristic = this, - ) -} - -internal fun CBCharacteristic.toLazyCharacteristic() = LazyCharacteristic( - serviceUuid = service.UUID.toUuid(), - characteristicUuid = UUID.toUuid(), -) diff --git a/core/src/appleMain/kotlin/PlatformDescriptor.kt b/core/src/appleMain/kotlin/PlatformDescriptor.kt deleted file mode 100644 index 6974964c8..000000000 --- a/core/src/appleMain/kotlin/PlatformDescriptor.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import platform.CoreBluetooth.CBDescriptor - -internal data class PlatformDescriptor( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - override val descriptorUuid: Uuid, - val cbDescriptor: CBDescriptor, -) : Descriptor - -internal fun PlatformDescriptor.toLazyDescriptor() = LazyDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = descriptorUuid, -) - -internal fun CBDescriptor.toPlatformDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, -) = PlatformDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = UUID.toUuid(), - cbDescriptor = this, -) diff --git a/core/src/appleMain/kotlin/PlatformService.kt b/core/src/appleMain/kotlin/PlatformService.kt deleted file mode 100644 index 3c4a1ce6b..000000000 --- a/core/src/appleMain/kotlin/PlatformService.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import platform.CoreBluetooth.CBCharacteristic -import platform.CoreBluetooth.CBService - -internal data class PlatformService( - override val serviceUuid: Uuid, - val cbService: CBService, - val characteristics: List, -) : Service - -internal fun PlatformService.toDiscoveredService() = DiscoveredService( - serviceUuid = serviceUuid, - characteristics = characteristics.map { it.toDiscoveredCharacteristic() }, -) - -internal fun CBService.toPlatformService(): PlatformService { - val serviceUuid = UUID.toUuid() - val platformCharacteristics = characteristics?.map { characteristic -> - characteristic as CBCharacteristic - characteristic.toPlatformCharacteristic(serviceUuid) - } ?: emptyList() - - return PlatformService( - serviceUuid = serviceUuid, - characteristics = platformCharacteristics, - cbService = this, - ) -} - -/** @throws NoSuchElementException if service or characteristic is not found. */ -internal fun List.findCharacteristic( - characteristic: Characteristic -): PlatformCharacteristic = - findCharacteristic( - serviceUuid = characteristic.serviceUuid, - characteristicUuid = characteristic.characteristicUuid - ) - -/** @throws NoSuchElementException if service or characteristic is not found. */ -private fun List.findCharacteristic( - serviceUuid: Uuid, - characteristicUuid: Uuid -): PlatformCharacteristic = - first(serviceUuid) - .characteristics - .first(characteristicUuid) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -internal fun List.findDescriptor( - descriptor: Descriptor -): PlatformDescriptor = - findDescriptor( - serviceUuid = descriptor.serviceUuid, - characteristicUuid = descriptor.characteristicUuid, - descriptorUuid = descriptor.descriptorUuid - ) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -private fun List.findDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, - descriptorUuid: Uuid -): PlatformDescriptor = - findCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid - ).descriptors.first(descriptorUuid) diff --git a/core/src/appleMain/kotlin/Profile.kt b/core/src/appleMain/kotlin/Profile.kt new file mode 100644 index 000000000..265e6179b --- /dev/null +++ b/core/src/appleMain/kotlin/Profile.kt @@ -0,0 +1,57 @@ +package com.juul.kable + +import com.benasher44.uuid.Uuid +import com.juul.kable.Characteristic.Properties +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBDescriptor +import platform.CoreBluetooth.CBService + +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformService = CBService +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformCharacteristic = CBCharacteristic +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformDescriptor = CBDescriptor + +public actual data class DiscoveredService internal constructor( + internal actual val service: PlatformService, +) : Service { + + public actual val characteristics: List = + service.characteristics + .orEmpty() + .map { it as PlatformCharacteristic } + .map(::DiscoveredCharacteristic) + + override val serviceUuid: Uuid = service.UUID.toUuid() +} + +public actual data class DiscoveredCharacteristic internal constructor( + internal actual val characteristic: PlatformCharacteristic, +) : Characteristic { + + public actual val descriptors: List = + characteristic.descriptors + .orEmpty() + .map { it as PlatformDescriptor } + .map(::DiscoveredDescriptor) + + override val serviceUuid: Uuid = characteristic.service.UUID.toUuid() + override val characteristicUuid: Uuid = characteristic.UUID.toUuid() + + public actual val properties: Properties = Properties(characteristic.properties.toInt()) +} + +public actual data class DiscoveredDescriptor internal constructor( + internal actual val descriptor: PlatformDescriptor, +) : Descriptor { + + override val serviceUuid: Uuid = descriptor.characteristic.service.UUID.toUuid() + override val characteristicUuid: Uuid = descriptor.characteristic.UUID.toUuid() + override val descriptorUuid: Uuid = descriptor.UUID.toUuid() +} + +internal fun PlatformCharacteristic.toLazyCharacteristic() = LazyCharacteristic( + serviceUuid = service.UUID.toUuid(), + characteristicUuid = UUID.toUuid(), +) diff --git a/core/src/commonMain/kotlin/Characteristic.kt b/core/src/commonMain/kotlin/Characteristic.kt deleted file mode 100644 index 2379289ce..000000000 --- a/core/src/commonMain/kotlin/Characteristic.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import com.benasher44.uuid.uuidFrom - -public fun characteristicOf( - service: String, - characteristic: String, -): Characteristic = LazyCharacteristic( - serviceUuid = uuidFrom(service), - characteristicUuid = uuidFrom(characteristic) -) - -public interface Characteristic { - public val serviceUuid: Uuid - public val characteristicUuid: Uuid -} - -public data class LazyCharacteristic internal constructor( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, -) : Characteristic - -public data class DiscoveredCharacteristic internal constructor( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - public val descriptors: List, -) : Characteristic - -internal fun List.first( - characteristicUuid: Uuid -): T = firstOrNull { it.characteristicUuid == characteristicUuid } - ?: throw NoSuchElementException("Characteristic $characteristicUuid not found") diff --git a/core/src/commonMain/kotlin/Descriptor.kt b/core/src/commonMain/kotlin/Descriptor.kt index 5a3fa79de..478a56ba3 100644 --- a/core/src/commonMain/kotlin/Descriptor.kt +++ b/core/src/commonMain/kotlin/Descriptor.kt @@ -1,29 +1,6 @@ package com.juul.kable import com.benasher44.uuid.Uuid -import com.benasher44.uuid.uuidFrom - -public fun descriptorOf( - service: String, - characteristic: String, - descriptor: String, -): Descriptor = LazyDescriptor( - serviceUuid = uuidFrom(service), - characteristicUuid = uuidFrom(characteristic), - descriptorUuid = uuidFrom(descriptor) -) - -public interface Descriptor { - public val serviceUuid: Uuid - public val characteristicUuid: Uuid - public val descriptorUuid: Uuid -} - -public data class LazyDescriptor( - public override val serviceUuid: Uuid, - public override val characteristicUuid: Uuid, - public override val descriptorUuid: Uuid, -) : Descriptor internal fun List.first( descriptorUuid: Uuid diff --git a/core/src/commonMain/kotlin/Peripheral.kt b/core/src/commonMain/kotlin/Peripheral.kt index e2c7167b4..6f079fe66 100644 --- a/core/src/commonMain/kotlin/Peripheral.kt +++ b/core/src/commonMain/kotlin/Peripheral.kt @@ -77,20 +77,48 @@ public interface Peripheral { */ public suspend fun disconnect(): Unit - /** @return discovered [services][Service], or `null` until a [connection][connect] has been established. */ + /** + * The list of services (GATT profile) which have been discovered on the remote peripheral. + * + * The list contains a tree of [DiscoveredService]s, [DiscoveredCharacteristic]s and [DiscoveredDescriptor]s. These + * types all hold strong references to the underlying platform type, so no guarantees are provided on the validity + * of the objects beyond a connection. If a reconnect occurs, it is recommended to retrieve the desired object from + * [services] again. Any references to objects obtained from this tree should be clear upon disconnect or disposal + * (when parent [CoroutineScope] is cancelled) of this [Peripheral]. + * + * @return [discovered services][DiscoveredService], or `null` until a [connection][connect] has been established. + */ public val services: List? /** @throws NotReadyException if invoked without an established [connection][connect]. */ @Throws(CancellationException::class, IOException::class, NotReadyException::class) public suspend fun rssi(): Int - /** @throws NotReadyException if invoked without an established [connection][connect]. */ + /** + * Reads data from [characteristic]. + * + * If [characteristic] was created via [characteristicOf] then the first found characteristic (matching the service + * UUID and characteristic UUID) in the GATT profile will be used. If multiple characteristics with the same UUID + * exist in the GATT profile, then a [discovered characteristic][DiscoveredCharacteristic] from [services] should be + * used instead. + * + * @throws NotReadyException if invoked without an established [connection][connect]. + */ @Throws(CancellationException::class, IOException::class, NotReadyException::class) public suspend fun read( characteristic: Characteristic, ): ByteArray - /** @throws NotReadyException if invoked without an established [connection][connect]. */ + /** + * Writes [data] to [characteristic]. + * + * If [characteristic] was created via [characteristicOf] then the first found characteristic (matching the service + * UUID and characteristic UUID) in the GATT profile will be used. If multiple characteristics with the same UUID + * exist in the GATT profile, then a [discovered characteristic][DiscoveredCharacteristic] from [services] should be + * used instead. + * + * @throws NotReadyException if invoked without an established [connection][connect]. + */ @Throws(CancellationException::class, IOException::class, NotReadyException::class) public suspend fun write( characteristic: Characteristic, @@ -98,13 +126,31 @@ public interface Peripheral { writeType: WriteType = WithoutResponse, ): Unit - /** @throws NotReadyException if invoked without an established [connection][connect]. */ + /** + * Reads data from [descriptor]. + * + * If [descriptor] was created via [descriptorOf] then the first found descriptor (matching the service UUID, + * characteristic UUID and descriptor UUID) in the GATT profile will be used. If multiple descriptors with the same + * UUID exist in the GATT profile, then a [discovered descriptor][DiscoveredDescriptor] from [services] should be + * used instead. + * + * @throws NotReadyException if invoked without an established [connection][connect]. + */ @Throws(CancellationException::class, IOException::class, NotReadyException::class) public suspend fun read( descriptor: Descriptor, ): ByteArray - /** @throws NotReadyException if invoked without an established [connection][connect]. */ + /** + * Writes [data] to [descriptor]. + * + * If [descriptor] was created via [descriptorOf] then the first found descriptor (matching the service UUID, + * characteristic UUID and descriptor UUID) in the GATT profile will be used. If multiple descriptors with the same + * UUID exist in the GATT profile, then a [discovered descriptor][DiscoveredDescriptor] from [services] should be + * used instead. + * + * @throws NotReadyException if invoked without an established [connection][connect]. + */ @Throws(CancellationException::class, IOException::class, NotReadyException::class) public suspend fun write( descriptor: Descriptor, diff --git a/core/src/commonMain/kotlin/Profile.kt b/core/src/commonMain/kotlin/Profile.kt new file mode 100644 index 000000000..45b500d5b --- /dev/null +++ b/core/src/commonMain/kotlin/Profile.kt @@ -0,0 +1,176 @@ +package com.juul.kable + +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuidFrom +import com.juul.kable.Characteristic.Properties +import com.juul.kable.WriteType.WithResponse +import com.juul.kable.WriteType.WithoutResponse +import kotlin.jvm.JvmInline + +public interface Service { + public val serviceUuid: Uuid +} + +/* ktlint-disable no-multi-spaces */ +internal val Broadcast = Properties(1 shl 0) // 0x01 +internal val Read = Properties(1 shl 1) // 0x02 +internal val WriteWithoutResponse = Properties(1 shl 2) // 0x04 +internal val Write = Properties(1 shl 3) // 0x08 +internal val Notify = Properties(1 shl 4) // 0x10 +internal val Indicate = Properties(1 shl 5) // 0x20 +internal val SignedWrite = Properties(1 shl 6) // 0x40 +internal val ExtendedProperties = Properties(1 shl 7) // 0x80 +/* ktlint-enable no-multi-spaces */ + +public val Properties.broadcast: Boolean + get() = value and Broadcast.value != 0 + +public val Properties.read: Boolean + get() = value and Read.value != 0 + +public val Properties.writeWithoutResponse: Boolean + get() = value and WriteWithoutResponse.value != 0 + +public val Properties.write: Boolean + get() = value and Write.value != 0 + +public val Properties.notify: Boolean + get() = value and Notify.value != 0 + +public val Properties.indicate: Boolean + get() = value and Indicate.value != 0 + +public val Properties.signedWrite: Boolean + get() = value and SignedWrite.value != 0 + +public val Properties.extendedProperties: Boolean + get() = value and ExtendedProperties.value != 0 + +internal val WriteType.properties: Properties + get() = when (this) { + WithResponse -> Write + WithoutResponse -> WriteWithoutResponse + } + +public interface Characteristic { + public val serviceUuid: Uuid + public val characteristicUuid: Uuid + + @JvmInline + public value class Properties internal constructor(public val value: Int) { + internal infix fun or(other: Properties): Properties = Properties(value or other.value) + internal infix fun and(other: Properties): Properties = Properties(value and other.value) + override fun toString(): String = + mutableListOf().apply { + if (broadcast) add("broadcast") + if (read) add("read") + if (writeWithoutResponse) add("writeWithoutResponse") + if (write) add("write") + if (notify) add("notify") + if (indicate) add("indicate") + if (signedWrite) add("signedWrite") + }.joinToString() + } +} + +public interface Descriptor { + public val serviceUuid: Uuid + public val characteristicUuid: Uuid + public val descriptorUuid: Uuid +} + +internal expect class PlatformService +internal expect class PlatformCharacteristic +internal expect class PlatformDescriptor + +/** Wrapper around platform specific Bluetooth LE service. Holds a strong reference to underlying service. */ +public expect class DiscoveredService : Service { + internal val service: PlatformService + public val characteristics: List +} + +/** Wrapper around platform specific Bluetooth LE characteristic. Holds a strong reference to underlying characteristic. */ +public expect class DiscoveredCharacteristic : Characteristic { + internal val characteristic: PlatformCharacteristic + public val descriptors: List + public val properties: Properties +} + +/** Wrapper around platform specific Bluetooth LE descriptor. Holds a strong reference to underlying descriptor. */ +public expect class DiscoveredDescriptor : Descriptor { + internal val descriptor: PlatformDescriptor +} + +public data class LazyCharacteristic internal constructor( + override val serviceUuid: Uuid, + override val characteristicUuid: Uuid, +) : Characteristic + +public data class LazyDescriptor( + public override val serviceUuid: Uuid, + public override val characteristicUuid: Uuid, + public override val descriptorUuid: Uuid, +) : Descriptor + +public fun characteristicOf( + service: String, + characteristic: String, +): Characteristic = LazyCharacteristic( + serviceUuid = uuidFrom(service), + characteristicUuid = uuidFrom(characteristic), +) + +public fun descriptorOf( + service: String, + characteristic: String, + descriptor: String, +): Descriptor = LazyDescriptor( + serviceUuid = uuidFrom(service), + characteristicUuid = uuidFrom(characteristic), + descriptorUuid = uuidFrom(descriptor), +) + +internal fun List.obtain( + characteristic: Characteristic, + properties: Properties?, +): PlatformCharacteristic { + if (characteristic is DiscoveredCharacteristic) return characteristic.characteristic + + val discoveredService = firstOrNull { + it.serviceUuid == characteristic.serviceUuid + } ?: throw NoSuchElementException("Service ${characteristic.serviceUuid} not found") + + val discoveredCharacteristic = discoveredService.characteristics.firstOrNull { + it.characteristicUuid == characteristic.characteristicUuid && + (properties == null || (it.properties and properties).value != 0) + } ?: throw NoSuchElementException("Characteristic ${characteristic.characteristicUuid}${properties.text}not found") + + return discoveredCharacteristic.characteristic +} + +internal fun List.obtain( + descriptor: Descriptor, +): PlatformDescriptor { + if (descriptor is DiscoveredDescriptor) return descriptor.descriptor + + val discoveredService = firstOrNull { + it.serviceUuid == descriptor.serviceUuid + } ?: throw NoSuchElementException("Service ${descriptor.serviceUuid} not found") + + val discoveredCharacteristic = discoveredService.characteristics.firstOrNull { + it.characteristicUuid == descriptor.characteristicUuid + } ?: throw NoSuchElementException("Characteristic ${descriptor.characteristicUuid} not found") + + val discoveredDescriptor = discoveredCharacteristic.descriptors.firstOrNull { + it.descriptorUuid == descriptor.descriptorUuid + } ?: throw NoSuchElementException("Descriptor ${descriptor.descriptorUuid} not found") + + return discoveredDescriptor.descriptor +} + +private val Properties?.text: String + get() = when (this?.value?.countOneBits()) { + null, 0 -> " " + 1 -> " with $this property " + else -> " with $this properties " + } diff --git a/core/src/commonMain/kotlin/Service.kt b/core/src/commonMain/kotlin/Service.kt deleted file mode 100644 index 5eb8c6288..000000000 --- a/core/src/commonMain/kotlin/Service.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid - -public interface Service { - public val serviceUuid: Uuid -} - -public data class DiscoveredService internal constructor( - override val serviceUuid: Uuid, - public val characteristics: List, -) : Service - -/** @throws NoSuchElementException if service is not found. */ -internal fun List.first( - serviceUuid: Uuid -): T = firstOrNull { it.serviceUuid == serviceUuid } - ?: throw NoSuchElementException("Service $serviceUuid not found") diff --git a/core/src/jsMain/kotlin/Peripheral.kt b/core/src/jsMain/kotlin/Peripheral.kt index 624313781..6c09863d3 100644 --- a/core/src/jsMain/kotlin/Peripheral.kt +++ b/core/src/jsMain/kotlin/Peripheral.kt @@ -78,12 +78,13 @@ public class JsPeripheral internal constructor( private val _state = MutableStateFlow(State.Disconnected()) public override val state: Flow = _state.asStateFlow() - private var _platformServices: List? = null - private val platformServices: List - get() = checkNotNull(_platformServices) { "Services have not been discovered for $this" } + private var _discoveredServices: List? = null + private val discoveredServices: List + get() = _discoveredServices + ?: throw IllegalStateException("Services have not been discovered for $this") public override val services: List? - get() = _platformServices?.map { it.toDiscoveredService() } + get() = _discoveredServices?.toList() private val observationListeners = mutableMapOf() @@ -178,9 +179,9 @@ public class JsPeripheral internal constructor( logger.verbose { message = "discover services" } val services = ioLock.withLock { gatt.getPrimaryServices().await() - .map { it.toPlatformService(logger) } + .map { it.toDiscoveredService(logger) } } - _platformServices = services + _discoveredServices = services } public override suspend fun write( @@ -194,11 +195,11 @@ public class JsPeripheral internal constructor( detail(data) } - val jsCharacteristic = bluetoothRemoteGATTCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) ioLock.withLock { when (writeType) { - WithResponse -> jsCharacteristic.writeValueWithResponse(data) - WithoutResponse -> jsCharacteristic.writeValueWithoutResponse(data) + WithResponse -> platformCharacteristic.writeValueWithResponse(data) + WithoutResponse -> platformCharacteristic.writeValueWithoutResponse(data) }.await() } } @@ -206,9 +207,9 @@ public class JsPeripheral internal constructor( public suspend fun readAsDataView( characteristic: Characteristic ): DataView { - val jsCharacteristic = bluetoothRemoteGATTCharacteristicFrom(characteristic) + val platformCharacteristic = discoveredServices.obtain(characteristic, Read) val value = ioLock.withLock { - jsCharacteristic.readValue().await() + platformCharacteristic.readValue().await() } logger.debug { message = "read" @@ -234,18 +235,18 @@ public class JsPeripheral internal constructor( detail(data) } - val jsDescriptor = bluetoothRemoteGATTDescriptorFrom(descriptor) + val platformDescriptor = discoveredServices.obtain(descriptor) ioLock.withLock { - jsDescriptor.writeValue(data).await() + platformDescriptor.writeValue(data).await() } } public suspend fun readAsDataView( descriptor: Descriptor ): DataView { - val jsDescriptor = bluetoothRemoteGATTDescriptorFrom(descriptor) + val platformDescriptor = discoveredServices.obtain(descriptor) val value = ioLock.withLock { - jsDescriptor.readValue().await() + platformDescriptor.readValue().await() } logger.debug { message = "read" @@ -293,7 +294,7 @@ public class JsPeripheral internal constructor( val listener = characteristic.createListener() observationListeners[characteristic] = listener - bluetoothRemoteGATTCharacteristicFrom(characteristic).apply { + discoveredServices.obtain(characteristic, Notify or Indicate).apply { addEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) ioLock.withLock { withContext(NonCancellable) { @@ -310,7 +311,7 @@ public class JsPeripheral internal constructor( detail(characteristic) } - bluetoothRemoteGATTCharacteristicFrom(characteristic).apply { + discoveredServices.obtain(characteristic, Notify or Indicate).apply { /* Throws `DOMException` if connection is closed: * * DOMException: Failed to execute 'stopNotifications' on 'BluetoothRemoteGATTCharacteristic': @@ -362,13 +363,5 @@ public class JsPeripheral internal constructor( bluetoothDevice.removeEventListener(GATT_SERVER_DISCONNECTED, disconnectedListener) } - private fun bluetoothRemoteGATTCharacteristicFrom( - characteristic: Characteristic - ) = platformServices.findCharacteristic(characteristic).bluetoothRemoteGATTCharacteristic - - private fun bluetoothRemoteGATTDescriptorFrom( - descriptor: Descriptor - ) = platformServices.findDescriptor(descriptor).bluetoothRemoteGATTDescriptor - override fun toString(): String = "Peripheral(bluetoothDevice=${bluetoothDevice.string()})" } diff --git a/core/src/jsMain/kotlin/PlatformCharacteristic.kt b/core/src/jsMain/kotlin/PlatformCharacteristic.kt deleted file mode 100644 index e5ac1ea9d..000000000 --- a/core/src/jsMain/kotlin/PlatformCharacteristic.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import com.juul.kable.external.BluetoothRemoteGATTCharacteristic -import com.juul.kable.logs.Logger -import com.juul.kable.logs.detail -import kotlinx.coroutines.await - -internal data class PlatformCharacteristic( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - val bluetoothRemoteGATTCharacteristic: BluetoothRemoteGATTCharacteristic, - val descriptors: List, -) : Characteristic - -internal fun PlatformCharacteristic.toDiscoveredCharacteristic() = DiscoveredCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptors = descriptors.map { it.toLazyDescriptor() }, -) - -internal suspend fun BluetoothRemoteGATTCharacteristic.toPlatformCharacteristic( - serviceUuid: Uuid, - logger: Logger, -): PlatformCharacteristic { - val characteristicUuid = uuid.toUuid() - val descriptors = runCatching { getDescriptors().await() } - .onFailure { cause -> - logger.error(cause) { - message = "Unable to retrieve descriptor." - detail(this@toPlatformCharacteristic) - } - } - .getOrDefault(emptyArray()) - val platformDescriptors = descriptors.map { descriptor -> - descriptor.toPlatformDescriptor(serviceUuid, characteristicUuid) - } - - return PlatformCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptors = platformDescriptors, - bluetoothRemoteGATTCharacteristic = this, - ) -} diff --git a/core/src/jsMain/kotlin/PlatformDescriptor.kt b/core/src/jsMain/kotlin/PlatformDescriptor.kt deleted file mode 100644 index 80d29c15f..000000000 --- a/core/src/jsMain/kotlin/PlatformDescriptor.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import com.juul.kable.external.BluetoothRemoteGATTDescriptor - -internal data class PlatformDescriptor( - override val serviceUuid: Uuid, - override val characteristicUuid: Uuid, - override val descriptorUuid: Uuid, - val bluetoothRemoteGATTDescriptor: BluetoothRemoteGATTDescriptor, -) : Descriptor - -internal fun PlatformDescriptor.toLazyDescriptor() = LazyDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = descriptorUuid, -) - -internal fun BluetoothRemoteGATTDescriptor.toPlatformDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, -) = PlatformDescriptor( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid, - descriptorUuid = uuid.toUuid(), - bluetoothRemoteGATTDescriptor = this, -) diff --git a/core/src/jsMain/kotlin/PlatformService.kt b/core/src/jsMain/kotlin/PlatformService.kt deleted file mode 100644 index c7fb6a6be..000000000 --- a/core/src/jsMain/kotlin/PlatformService.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.juul.kable - -import com.benasher44.uuid.Uuid -import com.juul.kable.external.BluetoothRemoteGATTService -import com.juul.kable.logs.Logger -import kotlinx.coroutines.await - -internal data class PlatformService( - override val serviceUuid: Uuid, - val bluetoothRemoteGATTService: BluetoothRemoteGATTService, - val characteristics: List, -) : Service - -internal fun PlatformService.toDiscoveredService() = DiscoveredService( - serviceUuid = serviceUuid, - characteristics = characteristics.map { it.toDiscoveredCharacteristic() }, -) - -internal suspend fun BluetoothRemoteGATTService.toPlatformService(logger: Logger): PlatformService { - val serviceUuid = uuid.toUuid() - val characteristics = getCharacteristics() - .await() - .map { characteristic -> - characteristic.toPlatformCharacteristic(serviceUuid, logger) - } - - return PlatformService( - serviceUuid = serviceUuid, - characteristics = characteristics, - bluetoothRemoteGATTService = this, - ) -} - -/** @throws NoSuchElementException if service or characteristic is not found. */ -internal fun List.findCharacteristic( - characteristic: Characteristic -): PlatformCharacteristic = - findCharacteristic( - serviceUuid = characteristic.serviceUuid, - characteristicUuid = characteristic.characteristicUuid - ) - -/** @throws NoSuchElementException if service or characteristic is not found. */ -private fun List.findCharacteristic( - serviceUuid: Uuid, - characteristicUuid: Uuid -): PlatformCharacteristic = - first(serviceUuid) - .characteristics - .first(characteristicUuid) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -internal fun List.findDescriptor( - descriptor: Descriptor -): PlatformDescriptor = - findDescriptor( - serviceUuid = descriptor.serviceUuid, - characteristicUuid = descriptor.characteristicUuid, - descriptorUuid = descriptor.descriptorUuid - ) - -/** @throws NoSuchElementException if service, characteristic or descriptor is not found. */ -private fun List.findDescriptor( - serviceUuid: Uuid, - characteristicUuid: Uuid, - descriptorUuid: Uuid -): PlatformDescriptor = - findCharacteristic( - serviceUuid = serviceUuid, - characteristicUuid = characteristicUuid - ).descriptors.first(descriptorUuid) diff --git a/core/src/jsMain/kotlin/Profile.kt b/core/src/jsMain/kotlin/Profile.kt new file mode 100644 index 000000000..9f67b7a7b --- /dev/null +++ b/core/src/jsMain/kotlin/Profile.kt @@ -0,0 +1,93 @@ +package com.juul.kable + +import com.benasher44.uuid.Uuid +import com.juul.kable.Characteristic.Properties +import com.juul.kable.external.BluetoothCharacteristicProperties +import com.juul.kable.external.BluetoothRemoteGATTCharacteristic +import com.juul.kable.external.BluetoothRemoteGATTDescriptor +import com.juul.kable.external.BluetoothRemoteGATTService +import com.juul.kable.logs.Logger +import com.juul.kable.logs.detail +import kotlinx.coroutines.await + +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformService = BluetoothRemoteGATTService +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformCharacteristic = BluetoothRemoteGATTCharacteristic +@Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 +internal actual typealias PlatformDescriptor = BluetoothRemoteGATTDescriptor +private typealias PlatformProperties = BluetoothCharacteristicProperties + +public actual data class DiscoveredService internal constructor( + internal actual val service: PlatformService, + public actual val characteristics: List, +) : Service { + + override val serviceUuid: Uuid = service.uuid.toUuid() +} + +public actual data class DiscoveredCharacteristic internal constructor( + internal actual val characteristic: PlatformCharacteristic, + public actual val descriptors: List, +) : Characteristic { + + override val serviceUuid: Uuid = characteristic.service.uuid.toUuid() + override val characteristicUuid: Uuid = characteristic.uuid.toUuid() + + public actual val properties: Properties = Properties(characteristic.properties) +} + +public actual data class DiscoveredDescriptor internal constructor( + internal actual val descriptor: PlatformDescriptor, +) : Descriptor { + + override val serviceUuid: Uuid = descriptor.characteristic.service.uuid.toUuid() + override val characteristicUuid: Uuid = descriptor.characteristic.uuid.toUuid() + override val descriptorUuid: Uuid = descriptor.uuid.toUuid() +} + +internal suspend fun PlatformService.toDiscoveredService(logger: Logger): DiscoveredService { + val characteristics = getCharacteristics() + .await() + .map { characteristic -> + characteristic.toDiscoveredCharacteristic(logger) + } + + return DiscoveredService( + service = this, + characteristics = characteristics, + ) +} + +private suspend fun BluetoothRemoteGATTCharacteristic.toDiscoveredCharacteristic( + logger: Logger, +): DiscoveredCharacteristic { + val descriptors = runCatching { getDescriptors().await() } + .onFailure { + logger.warn { + message = "Unable to retrieve descriptor." + detail(this@toDiscoveredCharacteristic) + } + } + .getOrDefault(emptyArray()) + val platformDescriptors = descriptors.map(::DiscoveredDescriptor) + + return DiscoveredCharacteristic( + characteristic = this, + descriptors = platformDescriptors, + ) +} + +private fun Properties(platformProperties: PlatformProperties): Properties { + var properties = Properties(0) + with(platformProperties) { + if (broadcast) properties = properties or Broadcast + if (read) properties = properties or Read + if (writeWithoutResponse) properties = properties or WriteWithoutResponse + if (write) properties = properties or Write + if (notify) properties = properties or Notify + if (indicate) properties = properties or Indicate + if (authenticatedSignedWrites) properties = properties or SignedWrite + } + return properties +} diff --git a/core/src/jsMain/kotlin/external/BluetoothCharacteristicProperties.kt b/core/src/jsMain/kotlin/external/BluetoothCharacteristicProperties.kt new file mode 100644 index 000000000..a967b6155 --- /dev/null +++ b/core/src/jsMain/kotlin/external/BluetoothCharacteristicProperties.kt @@ -0,0 +1,35 @@ +package com.juul.kable.external + +/** + * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothCharacteristicProperties + * https://webbluetoothcg.github.io/web-bluetooth/#characteristicproperties-interface + */ +internal external interface BluetoothCharacteristicProperties { + + /** Returns a boolean that is `true` if signed writing to the characteristic value is permitted. */ + val authenticatedSignedWrites: Boolean + + /** Returns a boolean that is `true` if the broadcast of the characteristic value is permitted using the Server Characteristic Configuration Descriptor. */ + val broadcast: Boolean + + /** Returns a boolean that is `true` if indications of the characteristic value with acknowledgement is permitted. */ + val indicate: Boolean + + /** Returns a boolean that is `true` if notifications of the characteristic value without acknowledgement is permitted. */ + val notify: Boolean + + /** Returns a boolean that is `true` if the reading of the characteristic value is permitted. */ + val read: Boolean + + /** Returns a boolean that is `true` if reliable writes to the characteristic is permitted. */ + val reliableWrite: Boolean + + /** Returns a boolean that is `true` if reliable writes to the characteristic descriptor is permitted. */ + val writableAuxiliaries: Boolean + + /** Returns a boolean that is `true` if the writing to the characteristic with response is permitted. */ + val write: Boolean + + /** Returns a boolean that is `true` if the writing to the characteristic without response is permitted. */ + val writeWithoutResponse: Boolean +} diff --git a/core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt b/core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt index 286e4e161..8e3c0bf86 100644 --- a/core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt +++ b/core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt @@ -13,6 +13,7 @@ internal abstract external class BluetoothRemoteGATTCharacteristic : EventTarget val service: BluetoothRemoteGATTService val uuid: UUID + val properties: BluetoothCharacteristicProperties val value: DataView? fun getDescriptor(descriptor: BluetoothDescriptorUUID): Promise diff --git a/core/src/jsMain/kotlin/external/BluetoothRemoteGATTDescriptor.kt b/core/src/jsMain/kotlin/external/BluetoothRemoteGATTDescriptor.kt index 56399784e..f468010fb 100644 --- a/core/src/jsMain/kotlin/external/BluetoothRemoteGATTDescriptor.kt +++ b/core/src/jsMain/kotlin/external/BluetoothRemoteGATTDescriptor.kt @@ -11,6 +11,7 @@ import kotlin.js.Promise */ internal abstract external class BluetoothRemoteGATTDescriptor : EventTarget { val uuid: UUID + val characteristic: BluetoothRemoteGATTCharacteristic fun readValue(): Promise fun writeValue(value: BufferSource): Promise }