Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,16 @@ interface BleConnection {
fun requestBalancedConnectionPriority(): Boolean = false

/**
* Clears the platform's cached GATT service table for the connected peripheral. Necessary when a device reboots
* into a different GATT profile (e.g., ESP32 OTA loader) on the same BLE MAC. Returns `true` if the cache was
* invalidated. Default implementation returns `false` for platforms without a service cache.
* Clears the platform's cached GATT service table for the connected peripheral, so the next discovery re-reads the
* device instead of replaying the cache. Necessary when a device reboots into a different GATT profile (e.g., ESP32
* OTA loader) on the same BLE MAC, and as recovery when a bonded device that was out of range for a long time
* reconnects against a cache the platform never refreshed.
*
* Requires a live connection: the implementation needs the platform's connection handle, so callers must invoke
* this while connected and then reconnect to pick up the fresh service table.
*
* Returns `true` if the cache was invalidated. Default implementation returns `false` for platforms without a
* service cache.
*/
fun invalidateServiceCache(): Boolean = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,14 @@ private val PRIORITY_DOWNGRADE_DELAY = 30.seconds
/**
* Settle delay after disconnecting to let the BLE stack release GATT resources before reconnecting post cache
* invalidation.
*
* Reuses [BleReconnectPolicy.DEFAULT_SETTLE_DELAY] (3 s) rather than a shorter bespoke value: this is the same
* disconnect → reconnect cycle the reconnect loop performs, and the same firmware-side GATT release has to complete
* first. The probe data recorded on that constant is explicit that 1.5 s fails 3–4 times out of 5 and that ≥ 5 s is
* needed for reliable reconnection, so a 500 ms pause here reliably turned a cache refresh into a failed attempt plus a
* fresh round of backoff — the opposite of the recovery it exists to provide.
*/
private val POST_INVALIDATION_RECONNECT_DELAY = 500.milliseconds
private val POST_INVALIDATION_RECONNECT_DELAY = BleReconnectPolicy.DEFAULT_SETTLE_DELAY

/**
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
Expand Down Expand Up @@ -234,6 +240,10 @@ class BleRadioTransport(
// toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)

// Because the loop above never gives up, a stale platform GATT cache would otherwise retry forever without
// recovering (issue #6685). This gate decides when a long failure streak has earned one cache refresh.
private val gattCacheInvalidationGate = GattCacheInvalidationGate()

private val heartbeatSender =
HeartbeatSender(
sendToRadio = { handleSendToRadio(it) },
Expand Down Expand Up @@ -380,27 +390,28 @@ class BleRadioTransport(
throw RadioNotConnectedException("Failed to connect to device at address $address")
}

// Post-OTA GATT cache invalidation: the device rebooted with potentially different
// BLE service table handles. Refresh Android's cache and reconnect to force fresh
// service discovery before proceeding with profile setup.
val radioServiceForCache = callback as? RadioInterfaceService
if (radioServiceForCache?.consumeGattCacheInvalidationRequest() == true) {
val invalidated = bleConnection.invalidateServiceCache()
Logger.d { "[${address.anonymize()}] Post-OTA GATT cache invalidation requested: $invalidated" }
if (invalidated) {
Logger.i {
"[${address.anonymize()}] Reconnecting after GATT cache refresh to force service rediscovery"
}
bleConnection.disconnect()
delay(POST_INVALIDATION_RECONNECT_DELAY)
val reconnectState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
if (reconnectState !is BleConnectionState.Connected) {
throw RadioNotConnectedException(
"Failed to reconnect after post-OTA GATT cache refresh (state=$reconnectState)",
)
}
Logger.i { "[${address.anonymize()}] Reconnected after GATT cache refresh" }
// GATT cache invalidation has two triggers, both repaired the same way — refresh the platform's cached
// service table, then reconnect so discovery re-reads the device:
// 1. Post-OTA: the device rebooted with potentially different BLE service table handles.
// 2. Stale cache after a long absence: a bonded radio that went out of range or powered off can come back
// with Android still serving the old cached service table, failing every attempt until the user unpairs
// at the OS level (issue #6685).
// 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 {
if (postOtaRequested) add("post-OTA reboot")
if (staleCacheSuspected) add("$consecutiveFailures consecutive reconnect failures")
}
val reason = triggers.joinToString(" + ")
// Only the stale-cache trigger spends the streak's one refresh. A post-OTA refresh is scheduled by the
// firmware-update flow, not by any failure streak, so charging it to the streak would disable stale-cache
// recovery for a streak that had not even started — and the gate can only be re-armed by a stable or
// intentional disconnect, which by definition cannot happen while a streak is running.
refreshGattCacheAndReconnect(device, reason, consumeStreakAllowance = staleCacheSuspected)
}

val gattConnectedAt = nowMillis
Expand Down Expand Up @@ -484,6 +495,12 @@ class BleRadioTransport(
val connectionUptime = (nowMillis - gattConnectedAt).milliseconds
val wasStable = connectionUptime >= reconnectPolicy.minStableConnection

// Mirror the outcomes that make BleReconnectPolicy reset its own failure counter: both end the streak, so the
// cache refresh must be re-armed here. It cannot be inferred from consecutiveFailures on the next attempt —
// that reset lands after this function returns, and a radio that is out of range again fails its next attempt
// without ever reaching the point where the counter is read.
if (wasStable || wasIntentional) gattCacheInvalidationGate.onFailureStreakEnded()

if (!wasStable && !wasIntentional) {
Logger.w {
"[$address] Connection lasted only $connectionUptime " +
Expand All @@ -494,6 +511,44 @@ class BleRadioTransport(
return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional)
}

/**
* Refreshes the platform's cached GATT service table for the connected peripheral and reconnects so that service
* discovery re-reads the device instead of replaying the cache.
*
* A no-op when the platform cannot invalidate (non-Android targets, or an Android reflection miss): nothing is
* disconnected and the current link is used as-is.
*
* @param reason short diagnostic describing which trigger asked for the refresh
* @param consumeStreakAllowance true only when the refresh was triggered by a suspected stale cache, so that a
* post-OTA refresh never spends the failure streak's single allowance
* @throws RadioNotConnectedException when the post-refresh reconnect does not reach Connected, so the caller
* returns a retryable failure to [BleReconnectPolicy]
*/
private suspend fun refreshGattCacheAndReconnect(
device: BleDevice,
reason: String,
consumeStreakAllowance: Boolean,
) {
val invalidated = bleConnection.invalidateServiceCache()
Logger.d { "[${address.anonymize()}] GATT cache invalidation ($reason): $invalidated" }
if (!invalidated) return

// Consume the streak's single allowance only for a stale-cache refresh, and only now that the platform
// reported a real refresh.
if (consumeStreakAllowance) gattCacheInvalidationGate.onCacheInvalidated()

Logger.i { "[${address.anonymize()}] Reconnecting after GATT cache refresh to force service rediscovery" }
bleConnection.disconnect()
delay(POST_INVALIDATION_RECONNECT_DELAY)
val reconnectState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
if (reconnectState !is BleConnectionState.Connected) {
throw RadioNotConnectedException(
"Failed to reconnect after GATT cache refresh ($reason, state=$reconnectState)",
)
}
Logger.i { "[${address.anonymize()}] Reconnected after GATT cache refresh ($reason)" }
}

private suspend fun bondDeviceBeforeConnect(device: BleDevice) {
if (bluetoothRepository.isBonded(address)) return

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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 the ordinary BLE reconnect loop should refresh the platform's cached GATT service table.
*
* Background: after a bonded radio goes out of range or power-cycles for a long time, Android can keep serving a stale
* cached service table for it. Every reconnect then discovers the wrong (or no) Meshtastic characteristics and fails,
* so [BleReconnectPolicy] retries forever and the UI never leaves "Not connected" — historically only an OS-level
* unpair/re-pair cleared it.
*
* Refreshing is not free: it costs an extra disconnect → settle → reconnect round trip and throws away a valid cache,
* so it must not fire on the ordinary out-of-range blip. A radio switched off for a minute, or a phone carried out of
* range and back, is not evidence of a stale cache — tearing down a link that was about to succeed would turn an
* ordinary reconnect into a failure plus a fresh round of backoff, which is worse than doing nothing at all.
*
* The gate therefore arms only once the failure streak has run for *minutes* (see [DEFAULT_FAILURE_THRESHOLD]) — the
* prolonged absence issue #6685 actually describes — and fires **at most once per streak**: if a genuine refresh did
* not fix the connection, repeating it every attempt only adds latency.
*
* Not thread-safe by design: it is owned by [BleRadioTransport]'s single reconnect-loop coroutine.
*
* @param failureThreshold consecutive reconnect failures required before a refresh is considered
*/
internal class GattCacheInvalidationGate(private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD) {

init {
require(failureThreshold > 0) { "failureThreshold must be positive, was $failureThreshold" }
}

/** True once a refresh actually took effect during the current failure streak. */
private var invalidatedForCurrentStreak: Boolean = false

/**
* Returns true when the connection attempt now in progress should refresh the GATT cache.
*
* A [consecutiveFailures] of zero can never reach [failureThreshold] (which is always positive), so a healthy
* connection never refreshes.
*
* @param consecutiveFailures failures observed *before* the attempt in progress, i.e.
* [BleReconnectPolicy.consecutiveFailures] read from inside the attempt
*/
fun shouldInvalidateOnAttempt(consecutiveFailures: Int): Boolean =
!invalidatedForCurrentStreak && consecutiveFailures >= failureThreshold

/**
* Records that a refresh actually took effect, consuming this streak's single allowance.
*
* Only call this when the platform reported success. A refresh that could not be performed at all (on Android the
* reflection hop into the `BluetoothGatt` can miss after a Kable upgrade) is a no-op that costs nothing, so it must
* not burn the allowance — otherwise one silent miss would disable the recovery for the rest of the streak.
*/
fun onCacheInvalidated() {
invalidatedForCurrentStreak = true
}

/**
* Re-arms the gate because the failure streak ended, so a later streak earns its own refresh.
*
* Must be driven by the same outcomes that reset [BleReconnectPolicy.consecutiveFailures]. The counter cannot be
* used to infer this: the caller only reads it on an attempt that reached a connected link, and the streak-ending
* reset lands *after* that attempt returns. A radio that is out of range again fails its next attempt long before
* any zero could be observed, which would leave the gate consumed forever and silently reintroduce issue #6685 the
* second time a device disappears.
*/
fun onFailureStreakEnded() {
invalidatedForCurrentStreak = false
}

companion object {
/**
* Consecutive failures before a stale cache is suspected.
*
* 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 or
* powered-off gap, so refreshing there would sabotage a normal reconnect rather than repair a stale cache.
*
* Six is the first count at which [computeReconnectBackoff] has been saturated at its 60 s cap for two
* consecutive cycles: the retry ladder is exhausted and every further attempt is identical. With a
* [BleReconnectPolicy.DEFAULT_SETTLE_DELAY] before each attempt, the refresh lands on the seventh attempt, 3
* min 36 s into an unbroken streak (seven settle delays plus 5+10+20+40+60+60 s of backoff). That is minutes of
* continuous failure, which is what issue #6685's "out of range / power off" report describes, rather than the
* sub-minute blip the lower threshold catches.
*/
const val DEFAULT_FAILURE_THRESHOLD = 6
}
}
Loading
Loading