refactor(ble): improve connection lifecycle and enhance OTA reliability (#4721)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-05 12:58:34 -06:00 committed by GitHub
parent 5a5aa1f026
commit 68b2b6d88e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 741 additions and 537 deletions

View file

@ -17,33 +17,29 @@
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.RemoteService
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
@ -61,12 +57,18 @@ class BleConnection(
var peripheral: Peripheral? = null
private set
private val _peripheral = MutableSharedFlow<Peripheral?>(replay = 1)
/** A flow of the current peripheral. */
val peripheralFlow = _peripheral.asSharedFlow()
private val _connectionState = simpleSharedFlow<ConnectionState>()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
private var stateJob: Job? = null
private var profileJob: Job? = null
/**
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
@ -77,6 +79,7 @@ class BleConnection(
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p
_peripheral.emit(p)
centralManager.connect(
peripheral = p,
@ -103,57 +106,32 @@ class BleConnection(
*
* @param p The peripheral to connect to.
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
* @param onRegister Optional block to run before connecting, allowing for profile registration.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long, onRegister: suspend () -> Unit = {}): ConnectionState {
onRegister()
connect(p)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
}
}
/** A flow of discovered services. Useful for reacting to "Service Changed" indications. */
val services: SharedFlow<List<RemoteService>> =
_connectionState
.asSharedFlow()
.filter { it is ConnectionState.Connected }
.flatMapLatest { peripheral?.services() ?: flowOf(emptyList()) }
.filterNotNull()
.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
/** Discovers characteristics for a specific service. */
suspend fun discoverCharacteristics(
serviceUuid: Uuid,
requiredUuids: List<Uuid>,
optionalUuids: List<Uuid> = emptyList(),
): Map<Uuid, RemoteCharacteristic>? {
val p = peripheral ?: return null
return retryBleOperation(tag = tag) {
val allRequested = requiredUuids + optionalUuids
val serviceList =
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
val service = serviceList.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
for (uuid in allRequested) {
val char = service.characteristics.find { it.uuid == uuid }
if (char != null) {
result[uuid] = char
}
}
val hasAllRequired = requiredUuids.all { result.containsKey(it) }
if (hasAllRequired) result else null
}
}
@Suppress("TooGenericExceptionCaught")
private fun observePeripheralDetails(p: Peripheral) {
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
.onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
.onEach { params ->
Logger.i { "[$tag] BLE connection parameters changed to $params" }
try {
val maxWriteLen = p.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
Logger.i { "[$tag] Negotiated MTU (Write): $maxWriteLen bytes" }
} catch (e: Exception) {
Logger.d { "[$tag] Could not read MTU: ${e.message}" }
}
}
.launchIn(scope)
}
@ -161,7 +139,65 @@ class BleConnection(
suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
profileJob?.cancel()
profileJob = null
peripheral?.disconnect()
peripheral = null
_peripheral.emit(null)
}
/**
* Executes a block within a discovered profile. Handles peripheral readiness, discovery with a timeout, and cleans
* up the profile job if discovery fails.
*
* @param serviceUuid The UUID of the service to discover.
* @param timeout The duration to wait for discovery.
* @param block The block to execute with the discovered service.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> profile(
serviceUuid: Uuid,
timeout: kotlin.time.Duration = 10.seconds,
setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
): T {
val p = peripheralFlow.first { it != null }!!
val serviceReady = CompletableDeferred<T>()
profileJob?.cancel()
val job =
scope.launch {
try {
val profileScope = this
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
try {
val result = setup(service)
serviceReady.complete(result)
// Keep the profile active until this launch scope (profileJob) is cancelled
awaitCancellation()
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
throw e
}
}
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
}
}
profileJob = job
return try {
withTimeout(timeout) { serviceReady.await() }
} catch (e: Throwable) {
profileJob?.cancel()
throw e
}
}
/** Returns the maximum write value length for the given write type. */
fun maximumWriteValueLength(writeType: WriteType): Int? = peripheral?.maximumWriteValueLength(writeType)
/** Requests a new connection priority for the current peripheral. */
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
peripheral?.requestConnectionPriority(priority)
}
}

View file

@ -1,135 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import no.nordicsemi.kotlin.ble.client.exception.ScanningException
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
import no.nordicsemi.kotlin.ble.core.exception.GattException
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
/**
* Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
* granular error handling and intelligent reconnection strategies.
*/
sealed class BleError(val message: String, val shouldReconnect: Boolean) {
/**
* An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
* reconnect.
*/
data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
/**
* An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
* warranted.
*/
class ConnectionFailed(exception: Throwable) :
BleError("Connection failed: ${exception.message}", shouldReconnect = true)
/**
* An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
* attempt is warranted.
*/
class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
/**
* An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
* is warranted.
*/
class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
/**
* Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
*
* @param exception The underlying GattException.
*/
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
/**
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
*
* @param exception The underlying BluetoothException.
*/
class BluetoothError(exception: BluetoothException) :
BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
/** The BLE manager was closed. This is a non-recoverable error. */
class ManagerClosed(exception: ManagerClosedException) :
BleError("Manager closed: ${exception.message}", shouldReconnect = false)
/** A BLE operation failed. This may be recoverable. */
class OperationFailed(exception: OperationFailedException) :
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
/**
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
*/
class InvalidAttribute(exception: InvalidAttributeException) :
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
/** An error occurred while scanning for devices. This may be recoverable. */
class Scanning(exception: ScanningException) :
BleError("Scanning error: ${exception.message}", shouldReconnect = true)
/** Bluetooth is unavailable on the device. This is a non-recoverable error. */
class BluetoothUnavailable(exception: BluetoothUnavailableException) :
BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
/** The peripheral is not connected. This may be recoverable. */
class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
/** A value did not match what was expected. This may be recoverable. */
class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
BleError("Value does not match: ${exception.message}", shouldReconnect = true)
/** A generic error for other exceptions that may occur. */
class GenericError(exception: Throwable) :
BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
companion object {
fun from(exception: Throwable): BleError = when (exception) {
is GattException -> {
when (exception) {
is ConnectionFailedException -> ConnectionFailed(exception)
is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
is OperationFailedException -> OperationFailed(exception)
is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
else -> GattError(exception)
}
}
is BluetoothException -> {
when (exception) {
is BluetoothUnavailableException -> BluetoothUnavailable(exception)
is InvalidAttributeException -> InvalidAttribute(exception)
is ScanningException -> Scanning(exception)
else -> BluetoothError(exception)
}
}
else -> GenericError(exception)
}
}
}

View file

@ -23,12 +23,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@ -47,5 +47,6 @@ object BleModule {
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatchers.default)
}

View file

@ -30,7 +30,6 @@ import kotlinx.coroutines.delay
* @return The result of the operation.
* @throws Exception if the operation fails after all attempts.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> retryBleOperation(
count: Int = 3,
delayMs: Long = 500L,
@ -43,7 +42,7 @@ suspend fun <T> retryBleOperation(
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
currentAttempt++
if (currentAttempt >= count) {
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.RemoteServices
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
@ -85,13 +86,7 @@ constructor(
}
internal suspend fun updateBluetoothState() {
val hasPerms =
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
val hasPerms = hasRequiredPermissions()
val enabled = androidEnvironment.isBluetoothEnabled
val newState =
BluetoothState(
@ -116,13 +111,7 @@ constructor(
@SuppressLint("MissingPermission")
fun isBonded(address: String): Boolean {
val enabled = androidEnvironment.isBluetoothEnabled
val hasPerms =
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
val hasPerms = hasRequiredPermissions()
return if (enabled && hasPerms) {
centralManager.getBondedPeripherals().any { it.address == address }
} else {
@ -130,10 +119,19 @@ constructor(
}
}
private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
val hasRequiredService =
(peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
?: false
return nameMatches || hasRequiredService
}

View file

@ -39,4 +39,15 @@ object MeshtasticBleConstants {
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
// --- OTA Characteristics ---
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
val OTA_SERVICE_UUID: Uuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
/** Characteristic for writing OTA commands and firmware data. */
val OTA_WRITE_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
/** Characteristic for receiving OTA status notifications/ACKs. */
val OTA_NOTIFY_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
}

View file

@ -124,4 +124,37 @@ class BluetoothRepositoryTest {
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
assertEquals(address, state.bondedDevices.first().address)
}
@Test
fun `isBonded returns false when permissions are not granted`() = runTest(testDispatcher) {
val noPermsEnv =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = false,
isBluetoothConnectPermissionGranted = false,
)
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
runCurrent()
assertFalse(repository.isBonded("C0:00:00:00:00:03"))
}
@Test
fun `state has no permissions when bluetooth permissions denied`() = runTest(testDispatcher) {
val noPermsEnv =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = true,
isBluetoothConnectPermissionGranted = false,
)
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
runCurrent()
val state = repository.state.value
assertFalse("hasPermissions should be false when connect permission is denied", state.hasPermissions)
}
}