mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-13 13:48:00 -04:00
Use Scan-Only Probes After Prolonged Bonded BLE Reconnect Failures (#6871)
This commit is contained in:
1 parent
1dc1ff4ed8
commit
91c70cbfee
5 files changed
+601
-95
No files matched your search
+131
@@ -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
|
||||
* (~1–2s 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
|
||||
}
|
||||
}
|
||||
+47
-86
@@ -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
|
||||
* (~1–2s 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 26–30 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 {
|
||||
|
||||
+81
@@ -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 26–30 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
|
||||
}
|
||||
}
|
||||
+231
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
+111
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user