diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index c2f5972d4..eb0bc7375 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -41,6 +41,10 @@ import kotlinx.coroutines.flow.onEach import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +// Number of service discovery attempts to make if no services are discovered. +// https://github.com/JuulLabs/kable/issues/295 +private const val DISCOVER_SERVICES_RETRIES = 5 + internal class BluetoothDeviceAndroidPeripheral( private val bluetoothDevice: BluetoothDevice, private val autoConnectPredicate: () -> Boolean, @@ -163,7 +167,7 @@ internal class BluetoothDeviceAndroidPeripheral( }.rssi private suspend fun discoverServices() { - connectionOrThrow().discoverServices() + connectionOrThrow().discoverServices(retries = DISCOVER_SERVICES_RETRIES) unwrapCancellationExceptions { onServicesDiscovered(ServicesDiscoveredPeripheral(this)) } diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index e6009ab64..1d069455e 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -25,6 +25,9 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch @@ -39,10 +42,6 @@ import kotlin.reflect.KClass import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO -// Number of service discovery attempts to make if no services are discovered. -// https://github.com/JuulLabs/kable/issues/295 -private const val DISCOVER_SERVICES_RETRIES = 5 - private val GattSuccess = GattStatus(GATT_SUCCESS) /** @@ -87,6 +86,7 @@ internal class Connection( require(disconnectTimeout > ZERO) { "Disconnect timeout must be >0, was $disconnectTimeout" } onDispose(::disconnect) + onServiceChanged(::discoverServices) on { val state = it.toString() @@ -96,18 +96,15 @@ internal class Connection( } dispose(NotConnectedException("Disconnect detected")) } - - // todo: Monitor onServicesChanged event to re-`discoverServices`. - // https://github.com/JuulLabs/kable/issues/662 } private val dispatcher = connectionScope.coroutineContext + threading.dispatcher private val guard = Mutex() - suspend fun discoverServices() { + suspend fun discoverServices(retries: Int = 1) { logger.verbose { message = "Discovering services" } - repeat(DISCOVER_SERVICES_RETRIES) { attempt -> + repeat(retries) { attempt -> val discoveredServices = execute { discoverServicesOrThrow() }.services.map(::DiscoveredService) @@ -115,7 +112,7 @@ internal class Connection( if (discoveredServices.isEmpty()) { logger.warn { message = "Empty services" - detail("attempt", "${attempt + 1} of $DISCOVER_SERVICES_RETRIES") + detail("attempt", "${attempt + 1} of $retries") } } else { logger.verbose { message = "Discovered ${discoveredServices.count()} services" } @@ -253,6 +250,13 @@ internal class Connection( } } + private fun onServiceChanged(action: suspend () -> Unit) { + callback.onServiceChanged + .receiveAsFlow() + .onEach { action() } + .launchIn(taskScope) + } + private fun onDispose(action: suspend () -> Unit) { @Suppress("OPT_IN_USAGE") connectionScope.launch(start = ATOMIC) { diff --git a/kable-core/src/androidMain/kotlin/gatt/Callback.kt b/kable-core/src/androidMain/kotlin/gatt/Callback.kt index 664cc3e50..33fc389f6 100644 --- a/kable-core/src/androidMain/kotlin/gatt/Callback.kt +++ b/kable-core/src/androidMain/kotlin/gatt/Callback.kt @@ -61,6 +61,7 @@ internal class Callback( val onResponse = Channel(CONFLATED) val onMtuChanged = Channel(CONFLATED) + val onServiceChanged = Channel(CONFLATED) override fun onPhyUpdate( gatt: BluetoothGatt, @@ -275,7 +276,7 @@ internal class Callback( override fun onServiceChanged(gatt: BluetoothGatt) { logger.debug { message = "onServiceChanged" } - // todo: https://github.com/JuulLabs/kable/issues/662 + onServiceChanged.trySendOrLog(OnServiceChanged) } private fun SendChannel.trySendOrLog(element: E) { diff --git a/kable-core/src/androidMain/kotlin/gatt/Response.kt b/kable-core/src/androidMain/kotlin/gatt/Response.kt index e9154ef19..e663fc523 100644 --- a/kable-core/src/androidMain/kotlin/gatt/Response.kt +++ b/kable-core/src/androidMain/kotlin/gatt/Response.kt @@ -97,6 +97,8 @@ internal data class OnMtuChanged( override val status: GattStatus, ) : Response() +internal object OnServiceChanged + /** * Represents the possible GATT statuses as defined in [BluetoothGatt]: * diff --git a/kable-core/src/appleMain/kotlin/Connection.kt b/kable-core/src/appleMain/kotlin/Connection.kt index c420d6435..b737f4a17 100644 --- a/kable-core/src/appleMain/kotlin/Connection.kt +++ b/kable-core/src/appleMain/kotlin/Connection.kt @@ -1,6 +1,5 @@ package com.juul.kable -import com.benasher44.uuid.Uuid import com.juul.kable.PeripheralDelegate.Response import com.juul.kable.PeripheralDelegate.Response.DidDiscoverCharacteristicsForService import com.juul.kable.PeripheralDelegate.Response.DidDiscoverDescriptorsForCharacteristic @@ -22,6 +21,9 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -66,6 +68,9 @@ internal class Connection( init { onDispose(::disconnect) + onServiceChanged { invalidatedServices -> + discoverServices(invalidatedServices.map { cbService -> cbService.UUID }) + } on { val state = it.toString() @@ -80,15 +85,11 @@ internal class Connection( private val dispatcher = connectionScope.coroutineContext + central.dispatcher internal val guard = Mutex() - suspend fun discoverServices(): Unit = discoverServices(serviceUuids = null) - /** @param serviceUuids to discover (list of service UUIDs), or `null` for all. */ - suspend fun discoverServices(serviceUuids: List?) { + suspend fun discoverServices(serviceUuids: List? = null) { logger.verbose { message = "discoverServices" } - val cbUuids = serviceUuids?.map { uuid -> CBUUID.UUIDWithNSUUID(uuid.toNSUUID()) } - execute { - peripheral.discoverServices(cbUuids) + peripheral.discoverServices(serviceUuids) } // Cast should be safe since `CBPeripheral.services` type is `[CBService]?`, according to: @@ -211,6 +212,13 @@ internal class Connection( } } + private fun onServiceChanged(action: suspend (List) -> Unit) { + delegate.onServiceChanged + .receiveAsFlow() + .onEach { (invalidatedServices) -> action(invalidatedServices) } + .launchIn(taskScope) + } + private fun onDispose(action: suspend () -> Unit) { @Suppress("OPT_IN_USAGE") connectionScope.launch(start = ATOMIC) { diff --git a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt index a66a6fe09..2ec630058 100644 --- a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt +++ b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt @@ -13,6 +13,7 @@ import com.juul.kable.logs.detail import kotlinx.cinterop.ObjCSignatureOverride import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -84,9 +85,15 @@ internal class PeripheralDelegate( ) : Response() } + data class OnServiceChanged( + val cbServices: List, + ) + private val _response = Channel(BUFFERED) val response: ReceiveChannel = _response + val onServiceChanged = Channel(CONFLATED) + private val logger = Logger(logging, tag = "Kable/Delegate", identifier = identifier) /* Discovering Services */ @@ -288,10 +295,16 @@ internal class PeripheralDelegate( peripheral: CBPeripheral, didModifyServices: List<*>, ) { + // Cast should be safe since `didModifyServices` type is `[CBService]`, according to: + // https://developer.apple.com/documentation/corebluetooth/cbperipheraldelegate/peripheral(_:didmodifyservices:) + @Suppress("UNCHECKED_CAST") + val invalidatedServices = didModifyServices as List + logger.debug { message = "didModifyServices" + detail("invalidatedServices", invalidatedServices.toString()) } - // todo + onServiceChanged.sendBlocking(OnServiceChanged(invalidatedServices)) } /* Monitoring L2CAP Channels */ diff --git a/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt b/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt index ae674ff2b..bcd1796b9 100644 --- a/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt @@ -38,6 +38,12 @@ internal val defaultDisconnectTimeout = 5.seconds public expect class PeripheralBuilder internal constructor() { public fun logging(init: LoggingBuilder) + + /** + * Registers a [ServicesDiscoveredAction] for the [Peripheral] that is invoked after initial + * service discover (upon establishing a connection). Is **not** invoked upon subsequent service + * re-discoveries (due to peripheral service database changing while connected). + */ public fun onServicesDiscovered(action: ServicesDiscoveredAction) /**