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..8847ad129 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/ProfileCommon { + 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..56c0dafa1 --- /dev/null +++ b/core/src/androidMain/kotlin/Profile.kt @@ -0,0 +1,52 @@ +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..cf68ac01a --- /dev/null +++ b/core/src/commonMain/kotlin/Profile.kt @@ -0,0 +1,179 @@ +@file:JvmName("ProfileCommon") + +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 +import kotlin.jvm.JvmName + +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 }