From 1d221e2ddeeb0746f5965649640542975a28b416 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 28 May 2026 12:16:19 -0700 Subject: [PATCH] fix(ble): stop BLE scan on background and downgrade connection priority (#5644) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/ble/KablePlatformSetup.kt | 7 ++++ .../org/meshtastic/core/ble/BleConnection.kt | 7 ++++ .../meshtastic/core/ble/KableBleConnection.kt | 2 + .../meshtastic/core/ble/KablePlatformSetup.kt | 6 +++ .../org/meshtastic/core/ble/NoopStubs.kt | 2 + .../meshtastic/core/ble/KablePlatformSetup.kt | 2 + .../core/network/radio/BleRadioTransport.kt | 38 ++++++++++++++----- .../connections/ui/ConnectionsScreen.kt | 21 +++++----- 8 files changed, 65 insertions(+), 20 deletions(-) diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index e9b4a58cc..a81a5a9b1 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -79,3 +79,10 @@ internal actual fun Peripheral.requestHighConnectionPriority(): Boolean { .onFailure { Logger.w(it) { "requestConnectionPriority(High) threw" } } .getOrDefault(false) } + +internal actual fun Peripheral.requestBalancedConnectionPriority(): Boolean { + val androidPeripheral = this as? AndroidPeripheral ?: return false + return runCatching { androidPeripheral.requestConnectionPriority(AndroidPeripheral.Priority.Balanced) } + .onFailure { Logger.w(it) { "requestConnectionPriority(Balanced) threw" } } + .getOrDefault(false) +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 0c3b83949..35cb3bee3 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -78,6 +78,13 @@ interface BleConnection { * Default implementation returns `false` for platforms that don't support it. */ fun requestHighConnectionPriority(): Boolean = false + + /** + * Requests the platform to return to balanced BLE connection priority (default ~30–50 ms interval). Call after + * latency-sensitive operations (initial config drain, DFU) to reduce ongoing battery draw. Default implementation + * returns `false` for platforms that don't support it. + */ + fun requestBalancedConnectionPriority(): Boolean = false } /** Represents a BLE service for commonMain. */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index 5bb26c8a5..6dc24c2c9 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -245,6 +245,8 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC override fun requestHighConnectionPriority(): Boolean = peripheral?.requestHighConnectionPriority() == true + override fun requestBalancedConnectionPriority(): Boolean = peripheral?.requestBalancedConnectionPriority() == true + /** Ensures the previous peripheral's GATT resources are fully released. */ private suspend fun cleanUpPeripheral(tag: String) { withContext(NonCancellable) { safeClosePeripheral(tag) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index a1f0baaef..791e3c62b 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -38,3 +38,9 @@ internal expect fun Peripheral.negotiatedMaxWriteLength(): Int? * no-op returning `false`. Used by latency-sensitive flows such as DFU firmware streaming. */ internal expect fun Peripheral.requestHighConnectionPriority(): Boolean + +/** + * Requests balanced BLE connection priority (default ~30–50 ms interval) to reduce battery draw after latency-sensitive + * operations complete. On platforms without an equivalent API (JVM/iOS) this is a no-op. + */ +internal expect fun Peripheral.requestBalancedConnectionPriority(): Boolean diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt index 85eb4fe7f..756fb9cfe 100644 --- a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt +++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt @@ -30,3 +30,5 @@ internal actual fun createPeripheral(address: String, builderAction: PeripheralB internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false + +internal actual fun Peripheral.requestBalancedConnectionPriority(): Boolean = false diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 462d7345d..16bd2fbc9 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -33,4 +33,6 @@ internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = DEFAULT_JVM_MT internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false +internal actual fun Peripheral.requestBalancedConnectionPriority(): Boolean = false + private const val DEFAULT_JVM_MTU = 512 diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 95512ecf4..37b90d526 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -78,6 +78,16 @@ private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds private val SCAN_TIMEOUT = 5.seconds private val GATT_CLEANUP_TIMEOUT = 5.seconds +/** + * Delay after onConnect before downgrading BLE connection priority to Balanced. + * + * The initial config drain (fromRadio burst) typically completes within 2–5 seconds on most devices, but slower radios + * (ESP32 with large node databases, many channels, or dense position history) can take significantly longer. 30 seconds + * provides generous margin while still ensuring we don't sustain the 7.5 ms connection interval indefinitely, which + * significantly increases battery draw on both the phone and the radio. + */ +private val PRIORITY_DOWNGRADE_DELAY = 30.seconds + /** * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable). * @@ -361,15 +371,7 @@ class BleRadioTransport( val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } - // Ask the platform for a low-latency / high-throughput connection interval - // (~7.5 ms on Android). The Meshtastic firmware happily accepts this and it - // materially speeds up the initial config drain and any bulk fromRadio reads. - if (bleConnection.requestHighConnectionPriority()) { - Logger.d { "[$address] Requested high BLE connection priority" } - // Wait for the connection parameter update to succeed before starting the heavy traffic - // in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147. - delay(1.seconds) - } + requestHighPriorityAndScheduleDowngrade() this@BleRadioTransport.callback.onConnect() } @@ -398,6 +400,24 @@ class BleRadioTransport( } } + /** + * Requests high BLE connection priority for the initial config burst, then schedules a downgrade to balanced + * priority after [PRIORITY_DOWNGRADE_DELAY] to conserve battery. + */ + private suspend fun CoroutineScope.requestHighPriorityAndScheduleDowngrade() { + if (bleConnection.requestHighConnectionPriority()) { + Logger.d { "[$address] Requested high BLE connection priority" } + // Wait for the connection parameter update before starting heavy traffic. + delay(1.seconds) + } + launch { + delay(PRIORITY_DOWNGRADE_DELAY) + if (bleConnection.requestBalancedConnectionPriority()) { + Logger.d { "[$address] Downgraded to balanced BLE connection priority" } + } + } + } + @Volatile private var radioService: MeshtasticRadioProfile? = null // --- RadioTransport Implementation --- diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index b52d5013d..d6e32c151 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -38,8 +38,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -136,17 +135,17 @@ fun ConnectionsScreen( onDenied = { scanModel.stopNetworkScan() }, ) - // Auto-start BLE scan on screen entry when the user has previously opted in. Stop on screen exit to save battery. - // We use `Unit` as the key so the effect is stable across recompositions — the toggle button manages scan - // start/stop directly and must not be interrupted by this effect re-firing when `bleAutoScan` changes. - // A LaunchedEffect watches bleAutoScan separately to handle the DataStore delivering `true` after the initial - // `false` (disk read latency), without triggering a dispose/restart cycle that kills an in-progress scan. - LaunchedEffect(bleAutoScan) { if (bleAutoScan && !scanModel.isBleScanning.value) scanModel.startBleScan() } - DisposableEffect(Unit) { onDispose { scanModel.stopBleScan() } } + // Auto-start BLE scan when the screen is visible (lifecycle ≥ STARTED) and the user has previously opted in. + // LifecycleStartEffect stops scanning on ON_STOP (app backgrounded) and restarts on ON_START — preventing + // continuous background BLE radio usage that drains the battery. + LifecycleStartEffect(bleAutoScan) { + if (bleAutoScan && !scanModel.isBleScanning.value) scanModel.startBleScan() + onStopOrDispose { scanModel.stopBleScan() } + } - DisposableEffect(networkAutoScan, localNetworkPermissionGranted) { + LifecycleStartEffect(networkAutoScan, localNetworkPermissionGranted) { if (networkAutoScan && localNetworkPermissionGranted) scanModel.startNetworkScan() - onDispose { scanModel.stopNetworkScan() } + onStopOrDispose { scanModel.stopNetworkScan() } } /* Animate waiting for the configurations */