feat(firmware): nRF52 legacy BLE DFU — stock-bootloader fixes + stranded-device recovery (#6041)

Signed-off-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-01 21:54:22 -05:00
committed by GitHub
parent d586e6cf62
commit 00ad90afdf
28 changed files with 795 additions and 110 deletions

View File

@@ -577,6 +577,10 @@ firebase_link
firmware
firmware_edition
firmware_old
firmware_recovery_banner
firmware_recovery_button
firmware_recovery_dismiss
firmware_recovery_explanation
firmware_too_old
firmware_update_almost_there
firmware_update_alpha

View File

@@ -49,6 +49,7 @@ private val CREATED_BOND_NONE_GRACE = BOND_STATE_POLL_INTERVAL + BOND_STATE_POLL
internal const val BOND_FAILED_OR_REJECTED_MESSAGE = "Bonding failed or rejected"
/** Android implementation of [BluetoothRepository]. */
@Suppress("TooManyFunctions")
@Single
class AndroidBluetoothRepository(
private val context: Context,
@@ -126,6 +127,27 @@ class AndroidBluetoothRepository(
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
@SuppressLint("MissingPermission")
override suspend fun removeBond(address: String): Boolean {
val remoteDevice = bluetoothAdapter?.getRemoteDevice(address)
if (remoteDevice == null || remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_NONE) {
return false
}
return try {
// removeBond() is a public-but-hidden BluetoothDevice API (no SDK stub); reflection is the standard access
// path used across the Android BLE/DFU ecosystem (incl. Nordic's DFU library).
val removed = remoteDevice.javaClass.getMethod("removeBond").invoke(remoteDevice) as? Boolean ?: false
Logger.i { "removeBond($address) -> $removed" }
removed
} catch (e: Exception) {
Logger.w(e) { "removeBond($address) reflection failed" }
false
} finally {
updateBluetoothState()
}
}
@Suppress("TooGenericExceptionCaught")
@SuppressLint("MissingPermission")
private fun startOrObserveBond(

View File

@@ -34,6 +34,15 @@ interface BluetoothRepository {
/** Initiates bonding with the given device. */
suspend fun bond(device: BleDevice)
/**
* Removes any existing bond for [address]. Returns true if a bond was present and removal was initiated.
*
* Needed before connecting to a nRF Legacy-DFU bootloader that re-advertises at the *same* address as the app (e.g.
* AdaDFU): a leftover bond makes the OS force stale link encryption the fresh bootloader can't satisfy, so it drops
* the link on the first DFU command. Default no-op for platforms/impls that don't manage bonds.
*/
suspend fun removeBond(address: String): Boolean = false
}
/** Represents the state of Bluetooth on the device. */

View File

@@ -0,0 +1,69 @@
/*
* 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.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
/**
* Persists a single [PendingFirmwareRecovery] record for a firmware update that may have left a device stranded in
* bootloader mode. Only the most recent trigger is retained (updates are one-device-at-a-time in practice).
*/
@Single
open class FirmwareRecoveryDataSource(
@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>,
) {
private object PreferencesKeys {
val PENDING_RECOVERY = stringPreferencesKey("pending-firmware-recovery")
}
/** The pending recovery record, or `null` when no interrupted update is outstanding. */
open val pending: Flow<PendingFirmwareRecovery?> =
dataStore.data.map { preferences ->
val jsonString = preferences[PreferencesKeys.PENDING_RECOVERY] ?: return@map null
runCatching { Json.decodeFromString<PendingFirmwareRecovery>(jsonString) }
.onFailure { e ->
if (e is IllegalArgumentException || e is SerializationException) {
Logger.w(e) { "Failed to parse pending firmware recovery, clearing preference" }
} else {
Logger.w(e) { "Unexpected error parsing pending firmware recovery" }
}
}
.getOrNull()
}
/** Records [recovery] as the outstanding interrupted update, replacing any previous record. */
open suspend fun set(recovery: PendingFirmwareRecovery) {
dataStore.edit { preferences -> preferences[PreferencesKeys.PENDING_RECOVERY] = Json.encodeToString(recovery) }
}
/** Clears the outstanding recovery record (update finished, or the device returned on its own). */
open suspend fun clear() {
dataStore.edit { preferences -> preferences.remove(PreferencesKeys.PENDING_RECOVERY) }
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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.datastore.model
import kotlinx.serialization.Serializable
/**
* A firmware update that was started but may not have finished, leaving the device stranded in nRF DFU/bootloader mode.
*
* Captured at DFU-trigger time (while still connected, when the hardware model and firmware channel are known) so the
* device can later be re-flashed while disconnected — the bootloader advertises indefinitely at `MAC+1` but exposes no
* mesh service, so there is otherwise no `address → hardware` link to reconstruct which firmware it needs.
*
* @property fullAddress The device address with its transport prefix (`radioPrefs.devAddr`), used to reconnect after
* recovery and to clear the record when the device returns on its own.
* @property hwModel The numeric hardware model, for re-resolving the [org.meshtastic.core.model.DeviceHardware].
* @property pioEnv The PlatformIO environment string, the second key for hardware resolution.
* @property releaseType The firmware channel name (`STABLE`/`ALPHA`) the interrupted update was flashing.
* @property deviceName The last-known device name, shown in the recovery prompt.
*/
@Serializable
data class PendingFirmwareRecovery(
val fullAddress: String,
val hwModel: Int,
val pioEnv: String,
val releaseType: String,
val deviceName: String,
)

View File

@@ -66,13 +66,27 @@ class MultiBackstack(val startTab: NavKey, private val currentTabState: MutableS
}
}
/** Sets the active tab and replaces its stack with the provided route path. */
/**
* Applies a deep-link backstack.
*
* If the path's root is a top-level tab (Connections/Nodes/Map/Settings/Messages), switch to that tab and replace
* its stack with the full path. If the root is instead a nested route that no tab owns — e.g. `FirmwareGraph`
* (reached from both Settings and Connections) or `WifiProvision` — push the path onto the *current* tab's stack
* rather than switching [currentTabRoute] to a key that has no backstack. Doing the latter made [activeBackStack]
* throw `Stack for <route> not found` and crashed the app on those deep links (e.g.
* `meshtastic://.../firmware/update`).
*/
fun handleDeepLink(navKeys: List<NavKey>) {
val rootKey = navKeys.firstOrNull() ?: return
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
currentTabRoute = topLevel
val stack = backStacks[topLevel] ?: return
stack.replaceAll(navKeys)
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route
if (topLevel != null) {
currentTabRoute = topLevel
backStacks[topLevel]?.replaceAll(navKeys)
} else {
// Nested route not owned by a tab: push onto the current tab's stack (mirrors how in-app code
// navigates to these via backStack.add) so the deep link opens and back returns to the app.
backStacks[currentTabRoute]?.addAll(navKeys)
}
}
}

View File

@@ -118,6 +118,41 @@ class MultiBackstackTest {
assertEquals(SettingsRoute.About, multiBackstack.activeBackStack.last())
}
@Test
fun `handleDeepLink to a nested non-tab route pushes onto current tab without crashing`() {
// Regression: deep-linking to firmware/update (root = FirmwareGraph, which is NOT a top-level
// tab) used to set currentTabRoute to FirmwareGraph — a key with no backstack — making the
// activeBackStack getter throw "Stack for FirmwareGraph not found" and crash the app.
val startTab = TopLevelDestination.Connect.route
val multiBackstack = createMultiBackstack(startTab)
val connectStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connect.route)) }
multiBackstack.backStacks = mapOf(TopLevelDestination.Connect.route to connectStack)
multiBackstack.handleDeepLink(listOf(FirmwareRoute.FirmwareGraph, FirmwareRoute.FirmwareUpdate))
// currentTabRoute must stay a real tab (not corrupted to FirmwareGraph)
assertEquals(TopLevelDestination.Connect.route, multiBackstack.currentTabRoute)
// the firmware routes are pushed onto the current tab's stack, and reading it does not throw
assertEquals(3, multiBackstack.activeBackStack.size)
assertEquals(TopLevelDestination.Connect.route, multiBackstack.activeBackStack.first())
assertEquals(FirmwareRoute.FirmwareUpdate, multiBackstack.activeBackStack.last())
}
@Test
fun `handleDeepLink to unknown nested route on missing tab is a no-op and never throws`() {
// Defensive: even if the current tab somehow has no backstack, a nested-route deep link must
// not throw (the getter would). Here backStacks is empty for the current tab.
val startTab = TopLevelDestination.Connect.route
val multiBackstack = createMultiBackstack(startTab)
multiBackstack.backStacks = emptyMap()
// Should simply do nothing rather than crash.
multiBackstack.handleDeepLink(listOf(FirmwareRoute.FirmwareGraph, FirmwareRoute.FirmwareUpdate))
assertEquals(TopLevelDestination.Connect.route, multiBackstack.currentTabRoute)
}
@Test
fun `handleDeepLink from different tab switches tab and sets stack`() {
// Start on Connect tab

View File

@@ -601,6 +601,11 @@
<string name="firmware">Firmware</string>
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
<string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_recovery_ble_failed">Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>
<string name="firmware_recovery_dismiss">Dismiss firmware recovery</string>
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>

View File

@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -38,6 +39,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.grant_permission
import org.meshtastic.core.resources.open_settings
import org.meshtastic.core.ui.icon.AppSettingsAlt
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.ErrorOutline
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.PermissionStatus
@@ -52,6 +54,10 @@ import org.meshtastic.core.ui.util.PermissionUiState
* @param actionLabel the recovery button label.
* @param onAction invoked when the recovery button is tapped.
* @param actionIcon optional leading icon for the recovery button.
* @param onDismiss optional dismiss handler; when non-null a trailing close (×) button is shown so the user can retire
* a card that can't otherwise be cleared (e.g. a firmware recovery that persistently fails on an unflashable
* bootloader). Omit for cards that must not be dismissable, such as missing-permission prompts.
* @param dismissContentDescription accessibility label for the dismiss button.
*/
@Composable
fun RecoveryCard(
@@ -60,6 +66,8 @@ fun RecoveryCard(
onAction: () -> Unit,
modifier: Modifier = Modifier,
actionIcon: ImageVector? = null,
onDismiss: (() -> Unit)? = null,
dismissContentDescription: String? = null,
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
@@ -82,7 +90,17 @@ fun RecoveryCard(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (onDismiss != null) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = MeshtasticIcons.Close,
contentDescription = dismissContentDescription,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}

View File

@@ -82,6 +82,7 @@ class AndroidScannerViewModelBondingTest {
bluetoothRepository = harness.bluetoothRepository,
usbRepository = inertUsbRepository(),
uiPrefs = harness.uiPrefs,
firmwareRecoveryDataSource = harness.firmwareRecoveryDataSource,
bleScanner = harness.bleScanner,
)
}

View File

@@ -26,6 +26,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.network.repository.NetworkRepository
@@ -57,6 +58,7 @@ class AndroidScannerViewModel(
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
uiPrefs: UiPrefs,
firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ScannerViewModel(
serviceRepository,
@@ -68,6 +70,7 @@ class AndroidScannerViewModel(
networkRepository,
dispatchers,
uiPrefs,
firmwareRecoveryDataSource,
bleScanner,
) {
override fun requestBonding(entry: DeviceListEntry.Ble) {

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
@@ -42,7 +43,9 @@ import org.meshtastic.core.ble.BleScanStartException
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.MeshtasticBleConstants
import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
@@ -87,6 +90,7 @@ open class ScannerViewModel(
private val networkRepository: NetworkRepository,
private val dispatchers: CoroutineDispatchers,
private val uiPrefs: UiPrefs,
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
private val bleScanner: BleScanner? = null,
) : ViewModel() {
@@ -161,6 +165,7 @@ open class ScannerViewModel(
.onEach { state ->
if (state is ConnectionState.Connected) {
stopAllScans()
clearRecoveryIfConnectedDeviceReturned()
}
}
.launchIn(viewModelScope)
@@ -239,6 +244,13 @@ open class ScannerViewModel(
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
discoveredDevicesFlow.map { it.recentTcpDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList())
/**
* A firmware update that didn't finish, leaving a device stranded in bootloader mode. Non-null drives the recovery
* banner on the Connections screen, whose tap deep-links into the Firmware Update screen to re-flash the device.
*/
val pendingRecovery: StateFlow<PendingFirmwareRecovery?> =
firmwareRecoveryDataSource.pending.distinctUntilChanged().stateInWhileSubscribed(initialValue = null)
// ── Current selection ────────────────────────────────────────────────────────────────────
/** The currently-selected device address, or `null` when nothing is selected. */
@@ -573,6 +585,29 @@ open class ScannerViewModel(
changeDeviceAddress(NO_DEVICE_SELECTED)
}
/**
* Manually retire the pending recovery record (user dismissed the banner). Needed for a device whose bootloader
* can't be re-flashed over BLE at all — e.g. a stock nRF Legacy-DFU bootloader that never answers its control point
* — where recovery persistently fails and would otherwise nag forever (it only auto-clears on a successful
* reconnect or re-flash). The record is re-created next time a DFU update is triggered.
*/
fun dismissRecovery() {
safeLaunch(tag = "dismissRecovery") { firmwareRecoveryDataSource.clear() }
}
/**
* If the device that had a pending recovery record has since reconnected normally (e.g. its bootloader timed out
* and booted a valid app, or a prior update actually succeeded), retire the record so the banner doesn't linger.
*/
private fun clearRecoveryIfConnectedDeviceReturned() {
safeLaunch(tag = "clearRecovery") {
val recovery = firmwareRecoveryDataSource.pending.first() ?: return@safeLaunch
if (recovery.fullAddress == radioInterfaceService.currentDeviceAddressFlow.value) {
firmwareRecoveryDataSource.clear()
}
}
}
private fun resolveActiveTransport(preferred: DeviceType?, selectedAddress: String?): DeviceType =
preferred ?: selectedAddress?.let(DeviceType::fromAddress) ?: DeviceType.BLE

View File

@@ -54,11 +54,15 @@ import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.navigation.FirmwareRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_disabled
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.firmware_recovery_banner
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_dismiss
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.open_bluetooth_settings
import org.meshtastic.core.resources.open_wifi_settings
@@ -124,6 +128,7 @@ fun ConnectionsScreen(
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle()
val pendingRecovery by scanModel.pendingRecovery.collectAsStateWithLifecycle()
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@@ -293,6 +298,27 @@ fun ConnectionsScreen(
}
}
// A device stranded in bootloader mode by an interrupted update can be re-flashed without
// reconnecting first. Shown only while disconnected so the Firmware screen enters its recovery
// path (it uses the live connection when connected); cleared automatically once the device
// returns on its own.
pendingRecovery
?.takeIf { connectionState !is ConnectionState.Connected }
?.let { recovery ->
Spacer(modifier = Modifier.height(8.dp))
RecoveryCard(
message = stringResource(Res.string.firmware_recovery_banner, recovery.deviceName),
actionLabel = stringResource(Res.string.firmware_recovery_button),
onAction = { onConfigNavigate(FirmwareRoute.FirmwareUpdate) },
actionIcon = MeshtasticIcons.Bluetooth,
// Let the user dismiss a recovery that can't succeed (e.g. an unflashable stock
// bootloader) so it doesn't nag forever; it otherwise only clears on
// reconnect/success.
onDismiss = { scanModel.dismissRecovery() },
dismissContentDescription = stringResource(Res.string.firmware_recovery_dismiss),
)
}
// Region warning sits outside the animated card so it does not affect the
// CONNECTED ↔ CONNECTING ↔ NO_DEVICE size transition.
val isPhysicalDevice =

View File

@@ -32,6 +32,7 @@ import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.repository.DiscoveredService
@@ -70,6 +71,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
val radioPrefs: RadioPrefs = mock(MockMode.autofill)
val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
val firmwareRecoveryDataSource: FirmwareRecoveryDataSource = mock(MockMode.autofill)
val networkRepository: NetworkRepository = mock(MockMode.autofill)
val bleScanner: BleScanner = mock(MockMode.autofill)
@@ -106,6 +108,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
every { radioInterfaceService.isMockTransport() } returns false
every { radioInterfaceService.currentDeviceAddressFlow } returns currentDeviceAddressFlow
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
every { firmwareRecoveryDataSource.pending } returns flowOf(null)
every { networkRepository.resolvedList } returns resolvedServicesFlow
every { networkRepository.networkAvailable } returns flowOf(true)
// Default: a non-completing scan flow so the BLE scan stays "active" until explicitly cancelled.
@@ -129,6 +132,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
networkRepository = networkRepository,
dispatchers = dispatchers,
uiPrefs = uiPrefs,
firmwareRecoveryDataSource = firmwareRecoveryDataSource,
bleScanner = bleScanner,
)

View File

@@ -20,6 +20,7 @@ import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
@@ -168,6 +169,16 @@ class ScannerViewModelTest {
assertEquals("test_address", radioController.lastSetDeviceAddress)
}
@Test
fun `dismissRecovery clears the pending recovery record`() = runTest {
// User dismisses a recovery that can't succeed (e.g. an unflashable bootloader) — the record
// must be cleared so the banner stops nagging.
viewModel.dismissRecovery()
testScheduler.advanceUntilIdle()
verifySuspend { harness.firmwareRecoveryDataSource.clear() }
}
@Test
fun `usbDevicesForUi emits updates`() = runTest {
viewModel.usbDevicesForUi.test {

View File

@@ -17,6 +17,7 @@
package org.meshtastic.feature.connections
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.repository.RadioController
@@ -44,6 +45,7 @@ class JvmScannerViewModel(
networkRepository: NetworkRepository,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
uiPrefs: UiPrefs,
firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ScannerViewModel(
serviceRepository,
@@ -55,5 +57,6 @@ class JvmScannerViewModel(
networkRepository,
dispatchers,
uiPrefs,
firmwareRecoveryDataSource,
bleScanner,
)

View File

@@ -58,6 +58,23 @@ class DefaultFirmwareUpdateManager(
)
}
override suspend fun recoverDfuDevice(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
): FirmwareArtifact? =
// Recovery is inherently a BLE DFU operation — route straight to the DFU handler rather than the
// connection-type dispatch in getHandler(), which would fail with no live connection. The handler derives
// MAC+1 from address and its buttonless trigger already no-ops when the device is already in DFU mode.
secureDfuHandler.startUpdate(
release = release,
hardware = hardware,
target = address,
updateState = updateState,
firmwareUri = null,
)
internal fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when {
radioPrefs.isSerial() -> {
if (hardware.isEsp32Arc) {

View File

@@ -42,4 +42,20 @@ interface FirmwareUpdateManager {
updateState: (FirmwareUpdateState) -> Unit,
firmwareUri: CommonUri? = null,
): FirmwareArtifact?
/**
* Recover a device stranded in nRF DFU/bootloader mode after an interrupted update, while the app is disconnected.
*
* Unlike [startUpdate], routing does not depend on the (absent) live connection — this always drives the BLE DFU
* handler, which derives the `MAC+1` bootloader address from [address] and tolerates a device that is already in
* DFU mode.
*
* @param address The device's normal mesh BLE address (transport prefix stripped).
*/
suspend fun recoverDfuDevice(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
): FirmwareArtifact?
}

View File

@@ -92,6 +92,8 @@ import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.chirpy
import org.meshtastic.core.resources.dont_show_again_for_device
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_explanation
import org.meshtastic.core.resources.firmware_update_almost_there
import org.meshtastic.core.resources.firmware_update_alpha
import org.meshtastic.core.resources.firmware_update_checking
@@ -269,12 +271,15 @@ private fun FirmwareUpdateScaffold(
) {
if (deviceHardware != null) {
Spacer(Modifier.height(16.dp))
AnimatedVisibility(
visible =
state is FirmwareUpdateState.Ready ||
state is FirmwareUpdateState.Idle ||
state is FirmwareUpdateState.Checking,
) {
// Recovery flashes the stored channel's current release, so the channel picker would mislead.
val showReleaseSelector =
(
state is FirmwareUpdateState.Ready ||
state is FirmwareUpdateState.Idle ||
state is FirmwareUpdateState.Checking
) &&
(state as? FirmwareUpdateState.Ready)?.isRecovery != true
AnimatedVisibility(visible = showReleaseSelector) {
Column {
ReleaseTypeSelector(selectedReleaseType, actions.onReleaseTypeSelect)
Spacer(Modifier.height(16.dp))
@@ -408,6 +413,16 @@ private fun ReadyState(
Spacer(Modifier.height(16.dp))
}
if (state.isRecovery) {
Text(
text = stringResource(Res.string.firmware_recovery_explanation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
Spacer(Modifier.height(16.dp))
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
@@ -453,10 +468,14 @@ private fun ReadyState(
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(
resource = Res.string.firmware_update_method_detail,
stringResource(state.updateMethod.description),
),
if (state.isRecovery) {
stringResource(Res.string.firmware_recovery_button)
} else {
stringResource(
resource = Res.string.firmware_update_method_detail,
stringResource(state.updateMethod.description),
)
},
style = ButtonDefaults.textStyleFor(largeHeight),
)
}

View File

@@ -52,6 +52,11 @@ sealed interface FirmwareUpdateState {
val showBootloaderWarning: Boolean,
val updateMethod: FirmwareUpdateMethod,
val currentFirmwareVersion: String? = null,
/**
* True when reached while disconnected to re-flash a device stranded in bootloader mode after an interrupted
* update (see [FirmwareUpdateViewModel.checkForUpdates]). Drives recovery-specific copy and routing.
*/
val isRecovery: Boolean = false,
) : FirmwareUpdateState
/** Firmware file is being downloaded from the release server. */

View File

@@ -43,6 +43,8 @@ import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MyNodeInfo
@@ -57,6 +59,7 @@ import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_recovery_ble_failed
import org.meshtastic.core.resources.firmware_update_battery_low
import org.meshtastic.core.resources.firmware_update_copying
import org.meshtastic.core.resources.firmware_update_extracting
@@ -92,6 +95,7 @@ class FirmwareUpdateViewModel(
private val radioController: RadioController,
private val radioPrefs: RadioPrefs,
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
@@ -119,6 +123,9 @@ class FirmwareUpdateViewModel(
private var tempFirmwareFile: FirmwareArtifact? = null
private var originalDeviceAddress: String? = null
/** Set when [checkForUpdates] enters recovery mode (disconnected + a saved record); consumed by [startUpdate]. */
private var pendingRecovery: PendingFirmwareRecovery? = null
init {
// Cleanup potential leftovers
viewModelScope.launch {
@@ -158,8 +165,9 @@ class FirmwareUpdateViewModel(
val ourNode = nodeRepository.myNodeInfo.value
val address = radioPrefs.devAddr.value?.drop(1)
if (address == null || ourNode == null) {
_state.value =
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_no_device))
// Not connected: offer to re-flash a device stranded in bootloader mode if we saved a
// recovery record when its (now-interrupted) update was triggered. Otherwise, no device.
enterRecoveryModeOrError()
return@launch
}
getDeviceHardware(ourNode)?.let { deviceHardware ->
@@ -226,9 +234,59 @@ class FirmwareUpdateViewModel(
}
}
/**
* Disconnected entry point: if a firmware update was interrupted and left a device stranded in bootloader mode,
* rebuild a recovery-flavored [FirmwareUpdateState.Ready] from the saved record so the user can re-flash it without
* first reconnecting (the bootloader exposes no mesh service to connect to). No record ⇒ the usual "no device".
*/
private suspend fun enterRecoveryModeOrError() {
val recovery = firmwareRecoveryDataSource.pending.first()
if (recovery == null) {
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_no_device))
return
}
pendingRecovery = recovery
val hardware =
deviceHardwareRepository.getDeviceHardwareByModel(recovery.hwModel, recovery.pioEnv).getOrElse {
_state.value =
FirmwareUpdateState.Error(
UiText.Resource(Res.string.firmware_update_unknown_hardware, recovery.hwModel),
)
null
} ?: return
_deviceHardware.value = hardware
_currentFirmwareVersion.value = null
val type =
runCatching { FirmwareReleaseType.valueOf(recovery.releaseType) }.getOrDefault(FirmwareReleaseType.STABLE)
_selectedReleaseType.value = type
firmwareReleaseRepository.getReleaseFlow(type).collectLatest { release ->
_selectedRelease.value = release
_state.value =
FirmwareUpdateState.Ready(
release = release,
deviceHardware = hardware,
address = recovery.fullAddress.drop(1),
showBootloaderWarning = false,
updateMethod = FirmwareUpdateMethod.Ble,
currentFirmwareVersion = null,
isRecovery = true,
)
}
}
fun startUpdate() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val release = currentState.release ?: return
if (currentState.isRecovery) {
startRecoveryUpdate(currentState, release)
} else {
startNormalUpdate(currentState, release)
}
}
private fun startNormalUpdate(currentState: FirmwareUpdateState.Ready, release: FirmwareRelease) {
originalDeviceAddress = radioPrefs.devAddr.value
viewModelScope.launch {
@@ -237,6 +295,9 @@ class FirmwareUpdateViewModel(
updateJob =
viewModelScope.launch {
try {
// Persist a recovery record before flashing so a stranded bootloader (interrupted upload,
// app closed, missed reconnect) can be re-flashed later while disconnected.
maybeRecordRecovery(currentState)
tempFirmwareFile =
firmwareUpdateManager.startUpdate(
release = release,
@@ -280,6 +341,82 @@ class FirmwareUpdateViewModel(
}
}
/**
* Persist a [PendingFirmwareRecovery] for the current BLE nRF-DFU update, so an interrupted flash that strands the
* device in bootloader mode can be recovered later. Scoped to BLE + non-ESP32 + a re-fetchable release channel
* (STABLE/ALPHA); ESP32 OTA and local-file flashes are intentionally not recoverable in this flow.
*/
private suspend fun maybeRecordRecovery(state: FirmwareUpdateState.Ready) {
val type = _selectedReleaseType.value
val recoverable =
state.updateMethod is FirmwareUpdateMethod.Ble &&
!state.deviceHardware.isEsp32Arc &&
type != FirmwareReleaseType.LOCAL
if (!recoverable) return
val fullAddress = radioPrefs.devAddr.value
val pioEnv = nodeRepository.myNodeInfo.value?.pioEnv
if (fullAddress == null || pioEnv == null) return
firmwareRecoveryDataSource.set(
PendingFirmwareRecovery(
fullAddress = fullAddress,
hwModel = state.deviceHardware.hwModel,
pioEnv = pioEnv,
releaseType = type.name,
deviceName = radioPrefs.devName.value ?: state.deviceHardware.displayName,
),
)
}
/**
* Re-flash a device stranded in bootloader mode. Routes straight to BLE DFU (the device is disconnected, so the
* connection-type dispatch can't run) and reuses the same verify/cleanup tail as a normal update.
*/
private fun startRecoveryUpdate(currentState: FirmwareUpdateState.Ready, release: FirmwareRelease) {
originalDeviceAddress = pendingRecovery?.fullAddress
updateJob?.cancel()
updateJob =
viewModelScope.launch {
try {
tempFirmwareFile =
firmwareUpdateManager.recoverDfuDevice(
release = release,
hardware = currentState.deviceHardware,
address = currentState.address,
updateState = { _state.value = it },
)
when (val finalState = _state.value) {
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
is FirmwareUpdateState.Error -> {
// BLE re-flash of a stranded device failed. A stock nRF bootloader can't reliably finish
// an interrupted OTA update over the air, so point the user at USB serial-DFU recovery
// rather than surfacing the low-level connection error.
_state.value =
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_recovery_ble_failed))
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
else -> {
Logger.w { "Firmware recovery returned without terminal state: ${_state.value}" }
_state.value =
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_recovery_ble_failed))
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
} catch (e: CancellationException) {
Logger.w(e) { "Firmware recovery cancelled" }
_state.value = FirmwareUpdateState.Idle
checkForUpdates()
throw e
} catch (e: Exception) {
Logger.e(e) { "Firmware recovery failed" }
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_recovery_ble_failed))
}
}
}
fun saveDfuFile(uri: CommonUri) {
val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return
val firmwareArtifact = currentState.uf2Artifact
@@ -407,6 +544,9 @@ class FirmwareUpdateViewModel(
Logger.w { "Post-update verification timed out for $address" }
_state.value = FirmwareUpdateState.VerificationFailed
} else {
// Device is back and healthy — retire any recovery record (covers both normal and recovery updates).
pendingRecovery = null
firmwareRecoveryDataSource.clear()
_state.value = FirmwareUpdateState.Success(wasLowSpeedTransfer)
}
}

View File

@@ -198,4 +198,14 @@ sealed class LegacyDfuException(message: String, cause: Throwable? = null) : Dfu
/** Bytes received reported by device differs from bytes sent past last PRN window. */
class PacketReceiptMismatch(expected: Long, actual: Long) :
LegacyDfuException("Packet receipt mismatch: expected $expected bytes received, device reports $actual")
/**
* START_DFU was rejected with `INVALID_STATE` because the bootloader still holds a previous, interrupted DFU
* session. The transport has issued a RESET; retrying the whole session (fresh reconnect) will start over on the
* now-clean bootloader. Retryable — distinct from a hard [ProtocolError].
*/
class StaleSessionReset :
LegacyDfuException(
"Bootloader rejected START with INVALID_STATE (leftover from an interrupted flash); reset and retrying.",
)
}

View File

@@ -217,7 +217,7 @@ class LegacyDfuTransport(
// ── 1. START_DFU + image sizes on Packet, then response ─────────────
writeControlPoint(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION))
writePacket(legacyImageSizesPayload(appSize = firmware.size))
requireSuccess(LegacyDfuOpcode.START_DFU, awaitResponse(START_RESPONSE_TIMEOUT))
handleStartResponse(awaitResponse(START_RESPONSE_TIMEOUT))
// ── 2. INIT_PARAMS_START → init bytes on Packet → INIT_PARAMS_COMPLETE → response ──
writeControlPoint(byteArrayOf(LegacyDfuOpcode.INIT_DFU_PARAMS, LegacyDfuOpcode.INIT_PARAMS_START))
@@ -408,13 +408,21 @@ class LegacyDfuTransport(
private suspend fun awaitResponse(timeout: Duration): LegacyDfuResponse = try {
withTimeout(timeout) {
// Drain any stray PRNs that arrive before the response we want.
while (true) {
val r = notificationChannel.receive()
if (r !is LegacyDfuResponse.PacketReceipt) return@withTimeout r
// Fail fast + accurately if the bootloader drops the link mid-handshake instead of answering. The stock
// same-MAC nRF Legacy bootloader (e.g. AdaDFU) commonly disconnects on the first Control Point command
// (stale-bond encryption mismatch); without this the receive() below just blocks until `timeout`, so
// the
// user waited the full 30s and saw a misleading "No response from Control Point" for what was really an
// immediate disconnect.
bleConnection.withDisconnectTripwire(onDrop = ::handshakeDropError) {
// Drain any stray PRNs that arrive before the response we want.
while (true) {
val r = notificationChannel.receive()
if (r !is LegacyDfuResponse.PacketReceipt) return@withDisconnectTripwire r
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
} catch (_: TimeoutCancellationException) {
throw DfuException.Timeout("No response from Legacy DFU Control Point after $timeout")
@@ -422,21 +430,50 @@ class LegacyDfuTransport(
private suspend fun awaitPacketReceipt(): LegacyDfuResponse.PacketReceipt = try {
withTimeout(COMMAND_TIMEOUT) {
while (true) {
val r = notificationChannel.receive()
if (r is LegacyDfuResponse.PacketReceipt) return@withTimeout r
if (r is LegacyDfuResponse.Failure) {
throw LegacyDfuException.ProtocolError(r.requestOpcode, r.status)
bleConnection.withDisconnectTripwire(onDrop = ::handshakeDropError) {
while (true) {
val r = notificationChannel.receive()
if (r is LegacyDfuResponse.PacketReceipt) return@withDisconnectTripwire r
if (r is LegacyDfuResponse.Failure) {
throw LegacyDfuException.ProtocolError(r.requestOpcode, r.status)
}
// Stray Success or Unknown → ignore.
}
// Stray Success or Unknown → ignore.
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
} catch (_: TimeoutCancellationException) {
throw DfuException.Timeout("No packet receipt notification after $COMMAND_TIMEOUT")
}
/** Error for a link drop while awaiting a Control Point response — distinguishes a disconnect from a true stall. */
private fun handshakeDropError(state: BleConnectionState): Throwable = DfuException.ConnectionFailed(
"BLE link dropped during DFU handshake (state=$state). The device disconnected before answering a DFU " +
"command — most often the stock Adafruit bootloader rebooting to the app. Retry, or use USB recovery.",
)
/**
* Validate the `START_DFU` response, with special handling for `INVALID_STATE`.
*
* A device whose previous DFU session was interrupted (link drop, app closed mid-stream) keeps its half-finished
* transfer state and rejects a fresh `START_DFU` with `INVALID_STATE` until it is reset. This is the common case
* when *recovering* a device stranded in the bootloader.
*
* We do NOT try to RESET on this connection: the stock Adafruit bootloader goes unresponsive immediately after
* emitting INVALID_STATE (the link dies by supervision timeout ~5 s later), so a write here never lands. Instead we
* fast-fail with [LegacyDfuException.StaleSessionReset]; [SecureDfuHandler] then resets the bootloader over a
* *fresh* connection (which is responsive up until START) before retrying. Mirrors the intent of Nordic
* `LegacyDfuImpl.resetAndRestart()`.
*/
private fun handleStartResponse(response: LegacyDfuResponse) {
if (response is LegacyDfuResponse.Failure && response.status == LegacyDfuStatus.INVALID_STATE) {
Logger.w { "Legacy DFU: START rejected with INVALID_STATE (stale session from an interrupted flash)" }
throw LegacyDfuException.StaleSessionReset()
}
requireSuccess(LegacyDfuOpcode.START_DFU, response)
}
private fun requireSuccess(expectedOpcode: Byte, response: LegacyDfuResponse) {
when (response) {
is LegacyDfuResponse.Success ->
@@ -476,7 +513,18 @@ class LegacyDfuTransport(
companion object {
private val CONNECT_TIMEOUT = 15.seconds
private val COMMAND_TIMEOUT = 30.seconds
private val START_RESPONSE_TIMEOUT = 30.seconds
/**
* Time to wait for the START_DFU response notification.
*
* The stock Adafruit nRF52 bootloader is single-bank: on START it erases the **entire** application bank (~800
* KB ≈ 200 flash pages) before firing the START-procedure response, and because the BLE link is live the
* SoftDevice time-slices each page erase against radio events, stretching the erase to ~30-50 s. The old 30 s
* cap aborted mid-erase (killing an otherwise-healthy session); Nordic's own DFU library imposes no such short
* cap here. 90 s covers the worst-case erase with margin. The disconnect tripwire still fast-fails on a genuine
* link drop, so this only extends the *silent-but-connected* wait.
*/
private val START_RESPONSE_TIMEOUT = 90.seconds
private val VALIDATE_TIMEOUT = 60.seconds
private val SUBSCRIPTION_SETTLE = 500.milliseconds
@@ -493,13 +541,14 @@ class LegacyDfuTransport(
* notification round-trips per byte and therefore faster throughput, at the cost of a slightly longer recovery
* window if a packet is dropped (we have to wait until the next PRN boundary to detect the gap).
*
* Set to 30 per the explicit recommendation from the Adafruit OTAFIX bootloader maintainer
* (https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#recommended-ota-dfu-settings — "Number of
* packets: 30"), which is the bootloader Meshtastic nRF52 devices ship with. Nordic's reference library
* defaults to 12; values above ~60 are not recommended for any host. Empirically 30 yields ~3x the throughput
* of PRN=10 on RAK4631 / OTAFIX without provoking OPERATION_FAILED on the bootloader's flash-write path.
* Capped at 10 to match Nordic's own Legacy DFU implementation, which force-limits legacy PRN to ≤10 with the
* comment: "DFU bootloaders from SDK 6.0.0 or older were unable to save incoming data to flash as fast as they
* are being sent … PRN = 10 may be the highest supported value" and treats status 6 (OPERATION_FAILED) as "data
* sent too fast — reduce PRN to 10 or less." The stock Adafruit bootloader shares this SDK11 flash-write path,
* so a higher value (we previously used 30, tuned for the faster OTAFIX fork) risks OPERATION_FAILED mid-stream
* on stock bootloaders. 10 is the safe ceiling that still batches flow-control ACKs.
*/
internal const val PRN_INTERVAL_PACKETS = 30
internal const val PRN_INTERVAL_PACKETS = 10
/**
* Universally-safe Legacy DFU packet size (20 bytes — the original ATT_MTU minus the 3-byte ATT header). Used

View File

@@ -130,79 +130,22 @@ class SecureDfuHandler(
// ── 4. Service detection: which DFU protocol does the bootloader speak? ─
val protocol = detectBootloaderProtocol(target, updateState)
Logger.i { "DFU: Bootloader protocol detected: $protocol" }
val transport: DfuUploadTransport =
when (protocol) {
DfuProtocolKind.LEGACY ->
LegacyDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
DfuProtocolKind.SECURE ->
SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
}
// NOTE: do NOT drop the bond for a same-address Legacy bootloader. When Meshtastic triggers
// buttonless DFU it hands the app's bond keys to the bootloader (peer_data), and the Adafruit
// bootloader then advertises the DFU service on the SAME address using whitelist filtering
// (BLE_GAP_ADV_FP_FILTER_BOTH) keyed to the bonded peer. Removing the bond strips the phone's
// identity so it no longer matches the whitelist — the phone then can't connect at all. The
// shared LTK also lets the DFU link encrypt cleanly (verified on-air: AES-128, keySize 16), so the
// bond must be KEPT, mirroring Nordic's DfuServiceInitiator.setKeepBond(true)/setRestoreBond(true).
var completed = false
try {
// ── 5. Connect to device in DFU mode ─────────────────────────────
connectWithRetry(transport, updateState)
// ── 6. Init packet ────────────────────────────────────────────
updateState(
FirmwareUpdateState.Processing(
ProgressState(UiText.Resource(Res.string.firmware_update_starting_dfu)),
),
)
Logger.i {
"DFU: Sending init packet (${pkg.initPacket.size} bytes) and firmware " +
"(${pkg.firmware.size} bytes) via $protocol"
}
transport.transferInitPacket(pkg.initPacket).getOrThrow()
// ── 7. Firmware ───────────────────────────────────────────────
val uploadMsg = UiText.Resource(Res.string.firmware_update_uploading)
// Surface a "slow bootloader" tip during the upload when the link couldn't negotiate a larger MTU
// (e.g. a stock Adafruit bootloader capped at MTU 23 → 20-byte packets, ~3.7 KB/s).
val slowHint =
if (transport.isLowSpeedTransfer) {
UiText.Resource(Res.string.firmware_update_slow_bootloader_hint)
} else {
null
}
updateState(FirmwareUpdateState.Updating(ProgressState(uploadMsg, 0f, hint = slowHint)))
val firmwareSize = pkg.firmware.size
val throughputTracker = ThroughputTracker()
transport
.transferFirmware(pkg.firmware) { progress ->
val bytesSent = (progress * firmwareSize).toLong()
throughputTracker.record(bytesSent)
val details =
formatTransferProgress(progress, firmwareSize, throughputTracker.bytesPerSecond())
updateState(
FirmwareUpdateState.Updating(
ProgressState(uploadMsg, progress, details, hint = slowHint),
),
)
}
.getOrThrow()
// ── 8. Validate ───────────────────────────────────────────────
updateState(
FirmwareUpdateState.Processing(
ProgressState(UiText.Resource(Res.string.firmware_update_validating)),
),
)
completed = true
updateState(FirmwareUpdateState.Success(wasLowSpeedTransfer = transport.isLowSpeedTransfer))
zipFile
} finally {
// Send ABORT if cancelled mid-transfer, then always clean up.
// NonCancellable ensures this runs even when the coroutine is being cancelled.
withContext(NonCancellable) {
if (!completed) transport.abort()
transport.close()
}
}
// Legacy DFU has no resume, so a failed session is retried whole (fresh transport + reconnect +
// re-handshake), mirroring Nordic's DFU library ("the Legacy DFU will start again"). A stock
// bootloader that leaves the first session's control-point handshake unanswered often responds
// after a clean reconnect. Secure DFU resumes in place, so it runs a single session.
val sessionAttempts = if (protocol == DfuProtocolKind.LEGACY) LEGACY_SESSION_ATTEMPTS else 1
runDfuUploadWithRetry(protocol, target, pkg, sessionAttempts, updateState)
zipFile
}
} catch (e: CancellationException) {
throw e
@@ -250,6 +193,126 @@ class SecureDfuHandler(
return if (legacyHit != null) DfuProtocolKind.LEGACY else DfuProtocolKind.SECURE
}
/**
* Run the connect + init + firmware upload, retrying the whole session up to [attempts] times. Each attempt uses a
* fresh [DfuUploadTransport] (new GATT connection + re-handshake) since Legacy DFU can't resume mid-stream.
*/
private suspend fun runDfuUploadWithRetry(
protocol: DfuProtocolKind,
target: String,
pkg: DfuZipPackage,
attempts: Int,
updateState: (FirmwareUpdateState) -> Unit,
) {
var lastError: Throwable? = null
repeat(attempts) { i ->
val attempt = i + 1
Logger.i { "DFU: upload session attempt $attempt/$attempts" }
try {
runUploadSession(protocol, target, pkg, updateState)
return
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
lastError = e
Logger.w(e) { "DFU: upload session $attempt/$attempts failed: ${e.message}" }
// A stock bootloader holding a wedged session from an interrupted flash rejects START with
// INVALID_STATE, then goes unresponsive (the RESET can't land on that connection). A *fresh*
// connection is responsive up until START, so reset it there — the device reboots into a clean OTA
// session (GPREGRET OTA flag is retained) that the next attempt can flash normally.
if (e is LegacyDfuException.StaleSessionReset && attempt < attempts) {
resetStaleBootloader(protocol, target)
}
if (attempt < attempts) delay(SESSION_RETRY_DELAY_MS)
}
}
throw lastError ?: DfuException.TransferFailed("DFU upload failed after $attempts attempts")
}
private fun createTransport(protocol: DfuProtocolKind, target: String): DfuUploadTransport = when (protocol) {
DfuProtocolKind.LEGACY -> LegacyDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
DfuProtocolKind.SECURE -> SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default)
}
/**
* Reboot a bootloader wedged in a stale DFU session. Connects a fresh transport (which is responsive before any
* START) and issues RESET (0x06) via [DfuUploadTransport.abort], then waits for the reboot + re-advertise. Best
* effort: any failure here is non-fatal — the caller retries the upload regardless.
*/
private suspend fun resetStaleBootloader(protocol: DfuProtocolKind, target: String) {
Logger.i { "DFU: reset-priming stale bootloader before retry" }
val transport = createTransport(protocol, target)
try {
transport
.connectToDfuMode()
.onSuccess {
transport.abort()
Logger.i { "DFU: reset-prime RESET sent; waiting for clean reboot" }
}
.onFailure { Logger.w(it) { "DFU: reset-prime connect failed: ${it.message}" } }
} finally {
withContext(NonCancellable) { transport.close() }
}
delay(RESET_PRIME_REBOOT_WAIT_MS)
}
/** A single connect + init-packet + firmware-upload session over a fresh transport; always cleans up. */
private suspend fun runUploadSession(
protocol: DfuProtocolKind,
target: String,
pkg: DfuZipPackage,
updateState: (FirmwareUpdateState) -> Unit,
) {
val transport: DfuUploadTransport = createTransport(protocol, target)
var completed = false
try {
connectWithRetry(transport, updateState)
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_starting_dfu))),
)
Logger.i {
"DFU: Sending init packet (${pkg.initPacket.size} bytes) and firmware " +
"(${pkg.firmware.size} bytes) via $protocol"
}
transport.transferInitPacket(pkg.initPacket).getOrThrow()
val uploadMsg = UiText.Resource(Res.string.firmware_update_uploading)
val slowHint =
if (transport.isLowSpeedTransfer) {
UiText.Resource(Res.string.firmware_update_slow_bootloader_hint)
} else {
null
}
updateState(FirmwareUpdateState.Updating(ProgressState(uploadMsg, 0f, hint = slowHint)))
val firmwareSize = pkg.firmware.size
val throughputTracker = ThroughputTracker()
transport
.transferFirmware(pkg.firmware) { progress ->
val bytesSent = (progress * firmwareSize).toLong()
throughputTracker.record(bytesSent)
val details = formatTransferProgress(progress, firmwareSize, throughputTracker.bytesPerSecond())
updateState(
FirmwareUpdateState.Updating(ProgressState(uploadMsg, progress, details, hint = slowHint)),
)
}
.getOrThrow()
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_validating))),
)
completed = true
updateState(FirmwareUpdateState.Success(wasLowSpeedTransfer = transport.isLowSpeedTransfer))
} finally {
withContext(NonCancellable) {
if (!completed) transport.abort()
transport.close()
}
}
}
private suspend fun connectWithRetry(transport: DfuUploadTransport, updateState: (FirmwareUpdateState) -> Unit) {
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
@@ -331,5 +394,14 @@ class SecureDfuHandler(
private companion object {
/** Detection scan timeout — short because we only want to confirm/refute an advertised legacy service. */
private val DETECT_SCAN_TIMEOUT = 8.seconds
/** Legacy DFU can't resume, so retry the whole session this many times before giving up. */
private const val LEGACY_SESSION_ATTEMPTS = 3
/** Delay between whole-session retries (lets the bootloader settle / resume advertising). */
private const val SESSION_RETRY_DELAY_MS = 2_000L
/** Wait after a reset-prime RESET for the bootloader to reboot and re-advertise a clean OTA session. */
private const val RESET_PRIME_REBOOT_WAIT_MS = 4_000L
}
}

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -62,6 +63,7 @@ class FirmwareUpdateIntegrationTest {
private val radioController = FakeRadioController()
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
private val bootloaderWarningDataSource: BootloaderWarningDataSource = mock(MockMode.autofill)
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource = mock(MockMode.autofill)
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
@@ -83,6 +85,7 @@ class FirmwareUpdateIntegrationTest {
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
Result.success(hardware)
everySuspend { bootloaderWarningDataSource.isDismissed(any()) } returns false
every { firmwareRecoveryDataSource.pending } returns flowOf(null)
every { fileHandler.cleanupAllTemporaryFiles() } returns Unit
everySuspend { fileHandler.deleteFile(any()) } returns Unit
@@ -105,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
radioController,
radioPrefs,
bootloaderWarningDataSource,
firmwareRecoveryDataSource,
firmwareUpdateManager,
usbManager,
fileHandler,

View File

@@ -35,6 +35,8 @@ import kotlinx.coroutines.test.setMain
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -67,6 +69,7 @@ class FirmwareUpdateViewModelTest {
private val radioController = FakeRadioController()
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
private val bootloaderWarningDataSource: BootloaderWarningDataSource = mock(MockMode.autofill)
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource = mock(MockMode.autofill)
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
@@ -90,6 +93,9 @@ class FirmwareUpdateViewModelTest {
everySuspend { bootloaderWarningDataSource.isDismissed(any()) } returns false
// No stranded-device recovery record by default; recovery tests override this.
every { firmwareRecoveryDataSource.pending } returns flowOf(null)
// Setup node info
nodeRepository.setMyNodeInfo(
TestDataFactory.createMyNodeInfo(myNodeNum = 123, firmwareVersion = "0.9.0", pioEnv = "tbeam"),
@@ -121,6 +127,7 @@ class FirmwareUpdateViewModelTest {
radioController,
radioPrefs,
bootloaderWarningDataSource,
firmwareRecoveryDataSource,
firmwareUpdateManager,
usbManager,
fileHandler,
@@ -397,4 +404,29 @@ class FirmwareUpdateViewModelTest {
assertIs<FirmwareUpdateState.Ready>(state)
assertEquals(null, state.release)
}
@Test
fun `checkForUpdates enters recovery mode when disconnected with a saved record`() = runTest {
// Disconnected (no live device) but a stranded-bootloader record exists → recovery Ready, not "no device".
every { radioPrefs.devAddr } returns MutableStateFlow(null)
every { firmwareRecoveryDataSource.pending } returns
flowOf(
PendingFirmwareRecovery(
fullAddress = "x1234abcd",
hwModel = 1,
pioEnv = "tbeam",
releaseType = "STABLE",
deviceName = "My Node",
),
)
viewModel = createViewModel()
advanceUntilIdle()
val state = viewModel.state.value
assertIs<FirmwareUpdateState.Ready>(state)
assertTrue(state.isRecovery, "Expected recovery Ready but was $state")
assertEquals("1234abcd", state.address) // fullAddress.drop(1)
assertIs<FirmwareUpdateMethod.Ble>(state.updateMethod)
}
}

View File

@@ -260,6 +260,18 @@ class LegacyDfuTransportTest {
assertEquals(LegacyDfuStatus.NOT_SUPPORTED, ex.status)
}
@Test
fun `transferFirmware maps START_DFU INVALID_STATE to StaleSessionReset for recovery restart`() = runTest {
val env = createConnectedTransport()
env.responder.scheme = LegacyResponderScheme.RejectStartInvalidState
env.transport.transferInitPacket(ByteArray(14)).getOrThrow()
val result = env.transport.transferFirmware(ByteArray(40)) {}
assertTrue(result.isFailure)
assertIs<LegacyDfuException.StaleSessionReset>(result.exceptionOrNull())
}
@Test
fun `transferFirmware fails with ProtocolError when device rejects INIT_DFU_PARAMS`() = runTest {
val env = createConnectedTransport()
@@ -394,6 +406,7 @@ class LegacyDfuTransportTest {
enum class LegacyResponderScheme {
HappyPath,
RejectStart,
RejectStartInvalidState,
RejectInit,
RejectValidate,
PrnUnderReport,
@@ -506,6 +519,9 @@ class LegacyDfuTransportTest {
LegacyResponderScheme.RejectStart ->
byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.START_DFU, LegacyDfuStatus.NOT_SUPPORTED)
LegacyResponderScheme.RejectStartInvalidState ->
byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.START_DFU, LegacyDfuStatus.INVALID_STATE)
else -> success(LegacyDfuOpcode.START_DFU)
}

View File

@@ -35,6 +35,7 @@ import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -64,6 +65,7 @@ class FirmwareUpdateViewModelFileTest {
private val radioController = FakeRadioController()
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
private val bootloaderWarningDataSource: BootloaderWarningDataSource = mock(MockMode.autofill)
private val firmwareRecoveryDataSource: FirmwareRecoveryDataSource = mock(MockMode.autofill)
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
@@ -85,6 +87,7 @@ class FirmwareUpdateViewModelFileTest {
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
Result.success(hardware)
everySuspend { bootloaderWarningDataSource.isDismissed(any()) } returns true
every { firmwareRecoveryDataSource.pending } returns flowOf(null)
nodeRepository.setMyNodeInfo(
TestDataFactory.createMyNodeInfo(myNodeNum = 123, firmwareVersion = "1.9.0", pioEnv = "tbeam"),
@@ -113,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
radioController,
radioPrefs,
bootloaderWarningDataSource,
firmwareRecoveryDataSource,
firmwareUpdateManager,
usbManager,
fileHandler,