Use Scan-Only Probes After Prolonged Bonded BLE Reconnect Failures (#6871)

This commit is contained in:
Jeremiah K authored and GitHub committed 2026-08-26 00:10:59 +00:00
1 parent 1dc1ff4ed8
commit 91c70cbfee
5 files changed
+601 -95

No files matched your search

@@ -0,0 +1,131 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("TooGenericExceptionCaught")
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.anonymize
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private val SCAN_RETRY_DELAY = 1.seconds
/**
* Bounded scan duration used by both discovery paths in [BleDeviceLocator.findDevice]:
* - Bonded devices get one address-filtered scan before falling back to the bonded handle.
* - Non-bonded retries each use this duration.
*
* Keeping the bonded path to one scanner registration is important on Android, which throttles applications that start
* BLE scans too frequently. A single 5s window still covers multiple advertising intervals for typical power-save slots
* (~12s each), resolves immediately when the target advertises, and avoids consuming two scan starts per reconnect. If
* the scan misses, [BleDeviceLocator.findDevice] falls back to the bonded handle and `attemptConnection` keeps that
* patient `autoConnect` path bounded through `CONNECTION_TIMEOUT`.
*/
internal val SCAN_TIMEOUT = 5.seconds
/**
* Locates the BLE device for one transport address: bonded-handle lookup with a single fresh-advertisement scan, or
* retried bounded scans for non-bonded addresses.
*
* Extracted from [BleRadioTransport] (unchanged behavior) so the transport class stays within detekt's LargeClass
* budget while sibling reconnect fixes continue to grow it.
*/
internal class BleDeviceLocator(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val address: String,
) {
/** Robustly finds the device. Checks bonded devices, preferring a fresh scan result when available. */
@Suppress("ReturnCount")
internal suspend fun findDevice(): BleDevice {
val bondedDevice =
bluetoothRepository.state.value.bondedDevices.firstOrNull { it.address.equals(address, ignoreCase = true) }
if (bondedDevice != null) {
// Use one bounded, address-filtered scan. Splitting this into a short scan plus an escalated scan consumed
// two Android scanner registrations per reconnect and could hit SCAN_FAILED_SCANNING_TOO_FREQUENTLY when a
// user switched devices while the reconnect policy and the Connections screen were also scanning.
Logger.i { "[${address.anonymize()}] Bonded device found; scanning once for a fresh advertisement" }
scanForFreshDevice(SCAN_TIMEOUT)?.let {
Logger.i { "[${address.anonymize()}] Fresh advertisement found; using scanned device" }
return it
}
// If the scan misses, fall back to the bonded handle. Bonded-only devices have no fresh advertisement, so
// Kable uses autoConnect=true and Android can patiently wait for the device to advertise again.
// This remains bounded by CONNECTION_TIMEOUT in connectAndAwait(), after which BleReconnectPolicy owns
// retry/backoff.
Logger.w {
"[${address.anonymize()}] No fresh advertisement within $SCAN_TIMEOUT; " +
"falling back to bonded handle for bounded autoConnect"
}
return bondedDevice
}
// Non-bonded path: preserve existing retry behavior (SCAN_RETRY_COUNT attempts at SCAN_TIMEOUT).
Logger.i { "[${address.anonymize()}] Device not found in bonded list, scanning" }
repeat(SCAN_RETRY_COUNT) { attempt ->
scanForFreshDevice(SCAN_TIMEOUT)?.let {
return it
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY)
}
}
throw RadioNotConnectedException("Device not found at address ${address.anonymize()}")
}
/**
* Performs a single BLE scan attempt for the selected [address] and returns the first matching [BleDevice], or null
* if the scan times out or fails.
*
* One scan attempt only — no retry, no backoff. Both bonded and non-bonded paths in [findDevice] share this
* primitive so retry policy stays centralized:
* - Bonded: one address-filtered [SCAN_TIMEOUT] attempt before [findDevice] returns the bonded handle.
* - Non-bonded: [SCAN_RETRY_COUNT] attempts at [SCAN_TIMEOUT] with [SCAN_RETRY_DELAY] between attempts.
*
* The outer [withTimeoutOrNull] is binding: the scanner receives [timeout] as a hint, but this coroutine resumes on
* its own schedule regardless of when (or whether) the scanner honors it.
*
* [CancellationException] is rethrown — coroutine cancellation must never be swallowed.
*/
internal suspend fun scanForFreshDevice(timeout: Duration): BleDevice? = try {
withTimeoutOrNull(timeout) {
// Pass both service UUID and address; the scanner picks whichever filter the platform can honour
// (address natively on Android, service UUID elsewhere) and narrows to the address itself.
scanner.scan(timeout = timeout, serviceUuid = SERVICE_UUID, address = address).first {
it.address.equals(address, ignoreCase = true)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.v(e) { "[${address.anonymize()}] Scan failed (timeout=$timeout)" }
null
}
}
@@ -64,12 +64,9 @@ import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportCallback
import kotlin.concurrent.Volatile
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private val SCAN_RETRY_DELAY = 1.seconds
private val CONNECTION_TIMEOUT = 15.seconds
/**
@@ -83,18 +80,6 @@ private val CONNECTION_TIMEOUT = 15.seconds
*/
private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds
/**
* Bounded scan duration used by both discovery paths in [findDevice]:
* - Bonded devices get one address-filtered scan before falling back to the bonded handle.
* - Non-bonded retries each use this duration.
*
* Keeping the bonded path to one scanner registration is important on Android, which throttles applications that start
* BLE scans too frequently. A single 5s window still covers multiple advertising intervals for typical power-save slots
* (~12s each), resolves immediately when the target advertises, and avoids consuming two scan starts per reconnect. If
* the scan misses, [findDevice] falls back to the bonded handle and [attemptConnection] keeps that patient
* `autoConnect` path bounded through [CONNECTION_TIMEOUT].
*/
internal val SCAN_TIMEOUT = 5.seconds
private val GATT_CLEANUP_TIMEOUT = 5.seconds
private val BLE_WRITE_OPERATION_TIMEOUT = 10.seconds
private const val BLE_MAX_PENDING_WRITES = 4
@@ -247,6 +232,16 @@ class BleRadioTransport(
// recovering (issue #6685). This gate decides when a long failure streak has earned one cache refresh.
private val gattCacheInvalidationGate = GattCacheInvalidationGate()
// For the same reason, a bonded radio that stays away would otherwise keep paying the full bonded-fallback price
// (a GATT open against Android's stale-GATT window plus up to CONNECTION_TIMEOUT) on every retry. This gate makes
// most long-streak attempts cheap scan-only probes while periodically yielding to bonded autoConnect as a self-heal
// when Android scanning is unavailable.
private val scanOnlyProbeGate = ScanOnlyProbeGate()
// Device discovery (bonded-first lookup + bounded scans) lives in BleDeviceLocator so this class stays within
// detekt's LargeClass budget.
private val deviceLocator = BleDeviceLocator(scanner, bluetoothRepository, address)
private val heartbeatSender =
HeartbeatSender(
// HeartbeatSender owns rejection severity. Suppress the generic offline-admission warning here so one
@@ -265,75 +260,6 @@ class BleRadioTransport(
// --- Connection & Discovery Logic ---
/** Robustly finds the device. Checks bonded devices, preferring a fresh scan result when available. */
@Suppress("ReturnCount")
private suspend fun findDevice(): BleDevice {
val bondedDevice =
bluetoothRepository.state.value.bondedDevices.firstOrNull { it.address.equals(address, ignoreCase = true) }
if (bondedDevice != null) {
// Use one bounded, address-filtered scan. Splitting this into a short scan plus an escalated scan consumed
// two Android scanner registrations per reconnect and could hit SCAN_FAILED_SCANNING_TOO_FREQUENTLY when a
// user switched devices while the reconnect policy and the Connections screen were also scanning.
Logger.i { "[${address.anonymize()}] Bonded device found; scanning once for a fresh advertisement" }
scanForFreshDevice(SCAN_TIMEOUT)?.let {
Logger.i { "[${address.anonymize()}] Fresh advertisement found; using scanned device" }
return it
}
// If the scan misses, fall back to the bonded handle. Bonded-only devices have no fresh advertisement, so
// Kable uses autoConnect=true and Android can patiently wait for the device to advertise again.
// This remains bounded by CONNECTION_TIMEOUT in connectAndAwait(), after which BleReconnectPolicy owns
// retry/backoff.
Logger.w {
"[${address.anonymize()}] No fresh advertisement within $SCAN_TIMEOUT; " +
"falling back to bonded handle for bounded autoConnect"
}
return bondedDevice
}
// Non-bonded path: preserve existing retry behavior (SCAN_RETRY_COUNT attempts at SCAN_TIMEOUT).
Logger.i { "[${address.anonymize()}] Device not found in bonded list, scanning" }
repeat(SCAN_RETRY_COUNT) { attempt ->
scanForFreshDevice(SCAN_TIMEOUT)?.let {
return it
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
/**
* Performs a single BLE scan attempt for the selected [address] and returns the first matching [BleDevice], or null
* if the scan times out or fails.
*
* One scan attempt only — no retry, no backoff. Both bonded and non-bonded paths in [findDevice] share this
* primitive so retry policy stays centralized:
* - Bonded: one address-filtered [SCAN_TIMEOUT] attempt before [findDevice] returns the bonded handle.
* - Non-bonded: [SCAN_RETRY_COUNT] attempts at [SCAN_TIMEOUT] with [SCAN_RETRY_DELAY] between attempts.
*
* The outer [withTimeoutOrNull] is binding: the scanner receives [timeout] as a hint, but this coroutine resumes on
* its own schedule regardless of when (or whether) the scanner honors it.
*
* [CancellationException] is rethrown — coroutine cancellation must never be swallowed.
*/
private suspend fun scanForFreshDevice(timeout: Duration): BleDevice? = try {
withTimeoutOrNull(timeout) {
// Pass both service UUID and address; the scanner picks whichever filter the platform can honour
// (address natively on Android, service UUID elsewhere) and narrows to the address itself.
scanner.scan(timeout = timeout, serviceUuid = SERVICE_UUID, address = address).first {
it.address.equals(address, ignoreCase = true)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.v(e) { "[${address.anonymize()}] Scan failed (timeout=$timeout)" }
null
}
private fun connect() {
connectionJob =
connectionScope.launch {
@@ -385,7 +311,43 @@ class BleRadioTransport(
awaitPendingSessionCleanup()
sessionFailed.value = false
val device = findDevice()
// Long-streak probe: once the retry ladder has saturated, a bonded device that is still absent fails most
// full-price attempts (GATT open + connection timeout) identically. Probe by scan alone instead — but
// deliberately yield at a fixed interval so a bonded autoConnect still gets recovery opportunities when Android
// scanning is unavailable (for example Location-off gating on API 2630 or a revoked scan permission on 31+).
// Any stable connection resets the reconnect streak, which disarms probing automatically.
val consecutiveFailures = reconnectPolicy.consecutiveFailures
val isBonded = bluetoothRepository.isBonded(address)
val shouldProbe = isBonded && scanOnlyProbeGate.shouldProbeInsteadOfBondedConnect(consecutiveFailures)
if (isBonded && consecutiveFailures >= scanOnlyProbeGate.failureThreshold && !shouldProbe) {
Logger.d {
"[${address.anonymize()}] $consecutiveFailures consecutive failures; " +
"trying periodic bonded fallback"
}
}
val probedDevice =
if (shouldProbe) {
// Entering probe mode earns one Info line; every continuation is Debug so a days-long absence does
// not repeat the same message at Info on every retry (the same noise class the heartbeat demotion
// addresses).
if (consecutiveFailures == scanOnlyProbeGate.failureThreshold) {
Logger.i {
"[${address.anonymize()}] $consecutiveFailures consecutive failures; probing by scan only"
}
} else {
Logger.d {
"[${address.anonymize()}] $consecutiveFailures consecutive failures; probing by scan only"
}
}
deviceLocator.scanForFreshDevice(SCAN_TIMEOUT)
?: throw RadioNotConnectedException("No advertisement during scan-only probe")
} else {
null
}
val device = probedDevice ?: deviceLocator.findDevice()
bondDeviceBeforeConnect(device)
@@ -404,7 +366,6 @@ class BleRadioTransport(
// consumeGattCacheInvalidationRequest() is read into a val first: `||` short-circuiting must never skip
// consuming the one-shot post-OTA flag.
val postOtaRequested = (callback as? RadioInterfaceService)?.consumeGattCacheInvalidationRequest() == true
val consecutiveFailures = reconnectPolicy.consecutiveFailures
val staleCacheSuspected = gattCacheInvalidationGate.shouldInvalidateOnAttempt(consecutiveFailures)
if (postOtaRequested || staleCacheSuspected) {
val triggers = buildList {
@@ -0,0 +1,81 @@
/*
* Copyright (c) 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.network.radio
/**
* Decides when a bonded device's reconnect attempt should be a cheap scan-only probe instead of the full bonded-handle
* connect.
*
* Background: while a bonded radio stays away (out of range or powered off), every reconnect iteration pays the full
* bonded-fallback price — a fresh GATT open against Android's stale-GATT window plus up to [CONNECTION_TIMEOUT] — only
* to fail again seconds later. Field logs show that pattern repeating every ~83 s for as long as the radio is gone.
*
* Probing is not free either: autoConnect from a bonded handle can succeed without any advertisement at all, so
* skipping it on early failures would trade away legitimate reconnect chances during an ordinary out-of-range blip.
* Like [GattCacheInvalidationGate], this gate therefore arms only after the failure streak has run for *minutes* — once
* the retry ladder has saturated at its 60 s cap and demonstrably stopped working — and disarms automatically when the
* streak ends ([BleReconnectPolicy] resets its counter on any stable connection).
*
* Scan visibility is not equivalent to connectability on Android: API 2630 can return no scan results when system
* Location is off, and API 31+ can lose `BLUETOOTH_SCAN` while a bonded `autoConnect` remains viable. To avoid making
* scan-only mode a permanent trap in either case, every [BONDED_FALLBACK_INTERVAL]th long-streak attempt deliberately
* yields to the normal bonded fallback.
*
* The gate is stateless; its answer is derived entirely from [consecutiveFailures].
*
* @param failureThreshold consecutive reconnect failures required before attempts become scan-only probes
*/
internal class ScanOnlyProbeGate(val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD) {
init {
require(failureThreshold > 0) { "failureThreshold must be positive, was $failureThreshold" }
}
/**
* Returns true when the attempt about to run should probe by scan alone rather than connect from the bonded handle.
*
* A [consecutiveFailures] of zero can never reach [failureThreshold] (which is always positive), so a healthy
* connection never probes. Once armed, every [BONDED_FALLBACK_INTERVAL]th long-streak attempt returns false so a
* bonded `autoConnect` still gets periodic recovery opportunities when Android scanning is unavailable.
*
* @param consecutiveFailures failures observed *before* the attempt in progress, i.e.
* [BleReconnectPolicy.consecutiveFailures] read from inside the attempt
*/
fun shouldProbeInsteadOfBondedConnect(consecutiveFailures: Int): Boolean {
if (consecutiveFailures < failureThreshold) return false
val longStreakAttempt = consecutiveFailures - failureThreshold + 1
return longStreakAttempt % BONDED_FALLBACK_INTERVAL != 0
}
companion object {
/**
* Consecutive failures before reconnect attempts switch to cheap scan-only probes.
*
* Deliberately far above [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] (3), which only marks a disconnect as
* "more than a blip" for the UI. Three failures are reached about 47 s into an ordinary out-of-range gap, where
* autoConnect still deserves its chance. Six is the first count at which [computeReconnectBackoff] has been
* saturated at its 60 s cap for two consecutive cycles — the same "minutes of unbroken failure" bar
* [GattCacheInvalidationGate.DEFAULT_FAILURE_THRESHOLD] uses for stale-cache recovery, so both escalations come
* online together on the seventh attempt.
*/
const val DEFAULT_FAILURE_THRESHOLD = 6
/** Long-streak attempts between deliberate retries of the normal bonded-handle connection path. */
const val BONDED_FALLBACK_INTERVAL = 5
}
}
@@ -28,11 +28,16 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.DisconnectReason
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
@@ -53,7 +58,9 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class)
class BleRadioTransportTest {
@@ -608,15 +615,18 @@ class BleRadioTransportTest {
}
}
private fun bleTransportOn(scope: CoroutineScope, callback: RadioInterfaceService): BleRadioTransport =
BleRadioTransport(
scope = scope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
callback = callback,
address = address,
)
private fun bleTransportOn(
scope: CoroutineScope,
callback: RadioInterfaceService,
bleScanner: BleScanner = scanner,
): BleRadioTransport = BleRadioTransport(
scope = scope,
scanner = bleScanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
callback = callback,
address = address,
)
/**
* Simulates the platform replaying a stale GATT service table: `profile()` cannot find the Meshtastic service, and
@@ -898,4 +908,216 @@ class BleRadioTransportTest {
bleTransport.close()
}
}
/**
* Once the failure streak has saturated the retry ladder ([ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD]), a bonded
* radio that is still absent must stop paying the full bonded-fallback price on every retry. When the radio starts
* advertising again, the reconnect must use that fresh device instance directly rather than the stale bonded
* handle.
*/
@Test
fun `long absence reconnect uses the fresh advertisement returned by the probe`() = runTest {
val threshold = ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD
val bondedDevice = FakeBleDevice(address = address, name = "Bonded Handle")
val freshDevice = FakeBleDevice(address = address, name = "Fresh Advertisement")
val controllableScanner = ControllableBleScanner()
bluetoothRepository.bond(bondedDevice)
connection.service.addCharacteristic(FROMNUM_CHARACTERISTIC)
connection.service.addCharacteristic(FROMRADIO_CHARACTERISTIC)
connection.failNextN = threshold
val bleTransport = bleTransportOn(this, service, controllableScanner)
bleTransport.start()
try {
advanceThroughFirstScanOnlyProbe(threshold)
assertEquals(
threshold,
connection.connectAndAwaitCalls,
"the first scan-only miss must not reach the bonded-handle connect",
)
assertEquals(
0,
connection.invalidateServiceCacheCalls,
"a probe miss never reaches a link, so the GATT cache path must stay untouched",
)
controllableScanner.advertisedDevice = freshDevice
advanceThroughCappedReconnect()
assertEquals(threshold + 1, connection.connectAndAwaitCalls)
assertTrue(
connection.device === freshDevice,
"the recovered connect must use the fresh advertisement, not the stale bonded handle",
)
} finally {
bleTransport.close()
}
}
/**
* Regression guard for the threshold boundary itself: every pre-threshold attempt reaches the bonded handle, while
* the first armed probe miss must leave the exact connect count unchanged.
*/
@Test
fun `the first scan-only probe skips the bonded connect exactly at the threshold`() = runTest {
val threshold = ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD
val device = FakeBleDevice(address = address, name = "Bonded Device")
bluetoothRepository.bond(device)
connection.failNextN = 100
val bleTransport =
BleRadioTransport(
scope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
callback = service,
address = address,
)
bleTransport.start()
try {
advanceTimeBy(elapsedThroughFailedBondedAttempts(threshold).inWholeMilliseconds)
runCurrent()
assertEquals(
threshold,
connection.connectAndAwaitCalls,
"every attempt through the threshold must still use the bonded fallback",
)
val firstProbe = computeReconnectBackoff(threshold) + BleReconnectPolicy.DEFAULT_SETTLE_DELAY + SCAN_TIMEOUT
advanceTimeBy(firstProbe.inWholeMilliseconds)
runCurrent()
assertEquals(
threshold,
connection.connectAndAwaitCalls,
"the first missed probe after the threshold must not add another bonded-handle connect",
)
} finally {
bleTransport.close()
}
}
/**
* The probe gate is bonded-only. A non-bonded address must keep the existing three-scan discovery sequence even
* after its reconnect failure count passes the probe threshold.
*/
@Test
fun `non bonded absence keeps full discovery retries beyond the probe threshold`() = runTest {
val threshold = ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD
val controllableScanner = ControllableBleScanner()
val bleTransport = bleTransportOn(this, service, controllableScanner)
bleTransport.start()
try {
val scansPerDiscovery = 3
val retryDelayCount = scansPerDiscovery - 1
val discoveryAttemptCost =
BleReconnectPolicy.DEFAULT_SETTLE_DELAY + SCAN_TIMEOUT * scansPerDiscovery + 1.seconds * retryDelayCount
val throughFirstPostThresholdAttempt =
discoveryAttemptCost * (threshold + 1) +
(1..threshold).fold(Duration.ZERO) { total, failures -> total + computeReconnectBackoff(failures) }
advanceTimeBy(throughFirstPostThresholdAttempt.inWholeMilliseconds)
runCurrent()
assertEquals(0, connection.connectAndAwaitCalls, "a missing non-bonded device never reaches GATT connect")
assertEquals(
scansPerDiscovery * (threshold + 1),
controllableScanner.scanCalls,
"non-bonded discovery must retain all three scan attempts beyond the bonded probe threshold",
)
} finally {
bleTransport.close()
}
}
/**
* Android scan availability can disappear independently of bonded-connect capability. When every scan remains
* empty, scan-only mode must still yield periodically so `autoConnect` gets another chance instead of becoming
* permanently unreachable behind the probe gate.
*/
@Test
fun `prolonged scan misses periodically retry the bonded fallback`() = runTest {
val threshold = ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD
val device = FakeBleDevice(address = address, name = "Bonded Device")
bluetoothRepository.bond(device)
connection.failNextN = 100
val bleTransport =
BleRadioTransport(
scope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
callback = service,
address = address,
)
bleTransport.start()
try {
val throughPeriodicFallback =
(0 until ScanOnlyProbeGate.BONDED_FALLBACK_INTERVAL).fold(
elapsedThroughFailedBondedAttempts(threshold),
) { elapsed, offset ->
elapsed +
computeReconnectBackoff(threshold + offset) +
BleReconnectPolicy.DEFAULT_SETTLE_DELAY +
SCAN_TIMEOUT
}
advanceTimeBy(throughPeriodicFallback.inWholeMilliseconds)
runCurrent()
assertEquals(
threshold + 1,
connection.connectAndAwaitCalls,
"the fifth long-streak attempt must retry bonded autoConnect even when scanning never sees the radio",
)
} finally {
bleTransport.close()
}
}
private fun TestScope.advanceThroughFirstScanOnlyProbe(threshold: Int) {
val firstProbeDone =
elapsedThroughFailedBondedAttempts(threshold) +
computeReconnectBackoff(threshold) +
BleReconnectPolicy.DEFAULT_SETTLE_DELAY +
SCAN_TIMEOUT
advanceTimeBy(firstProbeDone.inWholeMilliseconds)
runCurrent()
}
private fun TestScope.advanceThroughCappedReconnect() {
val budget =
BleReconnectPolicy.RECONNECT_MAX_DELAY + BleReconnectPolicy.DEFAULT_SETTLE_DELAY + SCAN_TIMEOUT + 1.seconds
advanceTimeBy(budget.inWholeMilliseconds)
runCurrent()
}
private fun elapsedThroughFailedBondedAttempts(attemptCount: Int): Duration {
require(attemptCount > 0)
val attemptCost = BleReconnectPolicy.DEFAULT_SETTLE_DELAY + SCAN_TIMEOUT
val backoffBeforeLast =
(1 until attemptCount).fold(Duration.ZERO) { total, failures -> total + computeReconnectBackoff(failures) }
return attemptCost * attemptCount + backoffBeforeLast
}
}
private class ControllableBleScanner : BleScanner {
var advertisedDevice: BleDevice? = null
var scanCalls: Int = 0
private set
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> = flow {
scanCalls++
advertisedDevice?.let {
emit(it)
return@flow
}
awaitCancellation()
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 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.network.radio
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
class ScanOnlyProbeGateTest {
@Test
fun `no probing while the failure streak is below the threshold`() {
val gate = ScanOnlyProbeGate(failureThreshold = 3)
assertFalse(gate.shouldProbeInsteadOfBondedConnect(0), "a healthy connection must never probe")
assertFalse(gate.shouldProbeInsteadOfBondedConnect(-1), "a nonsensical count must never probe")
assertFalse(gate.shouldProbeInsteadOfBondedConnect(1), "one failure is an ordinary out-of-range blip")
assertFalse(gate.shouldProbeInsteadOfBondedConnect(2), "two failures are still below the threshold")
}
@Test
fun `probing starts at the threshold and periodically yields to bonded fallback`() {
val gate = ScanOnlyProbeGate(failureThreshold = 3)
assertTrue(gate.shouldProbeInsteadOfBondedConnect(3), "the threshold-th consecutive failure must arm probing")
assertTrue(gate.shouldProbeInsteadOfBondedConnect(4))
assertTrue(gate.shouldProbeInsteadOfBondedConnect(5))
assertTrue(gate.shouldProbeInsteadOfBondedConnect(6))
assertFalse(
gate.shouldProbeInsteadOfBondedConnect(7),
"the fifth long-streak attempt must yield to bonded autoConnect as a scan-unavailable escape",
)
assertTrue(gate.shouldProbeInsteadOfBondedConnect(8), "probing must resume after the periodic bonded fallback")
}
@Test
fun `a stable reconnect policy outcome disarms probing`() {
val gate = ScanOnlyProbeGate()
val policy = BleReconnectPolicy()
repeat(ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD) {
policy.processOutcome(BleReconnectPolicy.Outcome.Failed(IllegalStateException("test failure")))
}
assertTrue(gate.shouldProbeInsteadOfBondedConnect(policy.consecutiveFailures))
policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false))
assertFalse(gate.shouldProbeInsteadOfBondedConnect(policy.consecutiveFailures))
}
/**
* Discriminator against the stale-GATT-window cost: a probe pass replaces a full bonded-handle connect (GATT open
* plus up to the platform connection timeout) with a cheap scan miss, so it must only arm once the retry ladder has
* demonstrably stopped working — not while early attempts are still likely to succeed.
*/
@Test
fun `the default threshold is far above the reconnect policy transient-disconnect threshold`() {
assertTrue(
ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD > BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD,
"probing at the transient-disconnect threshold would skip legitimate autoConnect chances " +
"(probe=${ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD}, " +
"transient=${BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD})",
)
}
/**
* Pins the intent behind the default rather than the number: probing may only start after *minutes* of unbroken
* failure, mirroring [GattCacheInvalidationGate]'s staleness bar. Lower-bound math assumes every pre-probe attempt
* failed instantly after its scan window; slower failures only push the first probe later.
*/
@Test
fun `the default threshold is only reachable after minutes of unbroken failure`() {
val threshold = ScanOnlyProbeGate.DEFAULT_FAILURE_THRESHOLD
val backoff =
(1..threshold).fold(Duration.ZERO) { total, failures -> total + computeReconnectBackoff(failures) }
// One settle delay plus one missed scan window precede every failing attempt.
val elapsedBeforeFirstProbe = (BleReconnectPolicy.DEFAULT_SETTLE_DELAY + SCAN_TIMEOUT) * threshold + backoff
assertTrue(
elapsedBeforeFirstProbe >= 3.minutes,
"probing must not be reachable inside an ordinary out-of-range gap (reached at $elapsedBeforeFirstProbe)",
)
assertTrue(
ScanOnlyProbeGate().shouldProbeInsteadOfBondedConnect(threshold),
"the timing discriminator must correspond to an actually armed gate",
)
}
@Test
fun `a non-positive threshold is rejected`() {
assertFailsWith<IllegalArgumentException> { ScanOnlyProbeGate(failureThreshold = 0) }
assertFailsWith<IllegalArgumentException> { ScanOnlyProbeGate(failureThreshold = -1) }
}
}