mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-02 09:26:01 -04:00
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:
4
.skills/compose-ui/strings-index.txt
generated
4
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class AndroidScannerViewModelBondingTest {
|
||||
bluetoothRepository = harness.bluetoothRepository,
|
||||
usbRepository = inertUsbRepository(),
|
||||
uiPrefs = harness.uiPrefs,
|
||||
firmwareRecoveryDataSource = harness.firmwareRecoveryDataSource,
|
||||
bleScanner = harness.bleScanner,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user