feat(firmware): link OTAFIX bootloader from slow-DFU success screen (#5917)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-23 13:34:58 -05:00
committed by GitHub
parent b6926700ca
commit 60b7908e1b
8 changed files with 46 additions and 16 deletions

View File

@@ -112,6 +112,7 @@ import org.meshtastic.core.resources.firmware_update_release_notes
import org.meshtastic.core.resources.firmware_update_retry
import org.meshtastic.core.resources.firmware_update_save_dfu_file
import org.meshtastic.core.resources.firmware_update_select_file
import org.meshtastic.core.resources.firmware_update_slow_bootloader_hint
import org.meshtastic.core.resources.firmware_update_source_local
import org.meshtastic.core.resources.firmware_update_stable
import org.meshtastic.core.resources.firmware_update_success
@@ -150,6 +151,14 @@ import org.meshtastic.core.ui.util.rememberSaveFileLauncher
private const val CYCLE_DELAY_MS = 4500L
/**
* Flashing instructions for the OTAFIX 2.1 bootloader, which lifts the BLE DFU MTU cap (20-byte → 244-byte packets,
* ~10× faster updates). Offered on the Success screen after a low-speed transfer — never mid-upload, where leaving the
* app could drop the DFU link and brick the device.
*/
private const val OTAFIX_BOOTLOADER_URL =
"https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#changes-in-otafix-21"
@Composable
@Suppress("LongMethod")
fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateViewModel, modifier: Modifier = Modifier) {
@@ -328,7 +337,8 @@ private fun FirmwareUpdateContent(
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry)
is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone)
is FirmwareUpdateState.Success ->
SuccessState(onDone = actions.onDone, wasLowSpeedTransfer = state.wasLowSpeedTransfer)
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile)
}
@@ -866,8 +876,9 @@ internal fun ErrorState(error: UiText, onRetry: () -> Unit) {
}
@Composable
internal fun SuccessState(onDone: () -> Unit) {
internal fun SuccessState(wasLowSpeedTransfer: Boolean = false, onDone: () -> Unit) {
val haptic = LocalHapticFeedback.current
val openUrl = rememberOpenUrl()
LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -884,6 +895,18 @@ internal fun SuccessState(onDone: () -> Unit) {
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
// The just-finished transfer was MTU-capped (stock bootloader). Now that the device is back and it's safe to
// leave the app, offer a one-time OTAFIX upgrade tip for faster future updates.
if (wasLowSpeedTransfer) {
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(Res.string.firmware_update_slow_bootloader_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
TextButton(onClick = { openUrl(OTAFIX_BOOTLOADER_URL) }) { Text(stringResource(Res.string.learn_more)) }
}
Spacer(Modifier.height(32.dp))
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val largeHeight = ButtonDefaults.LargeContainerHeight

View File

@@ -72,8 +72,13 @@ sealed interface FirmwareUpdateState {
/** An error occurred at any stage of the update pipeline. */
data class Error(val error: UiText) : FirmwareUpdateState
/** The firmware update completed and the device reconnected successfully. */
data object Success : FirmwareUpdateState
/**
* The firmware update completed and the device reconnected successfully.
*
* @property wasLowSpeedTransfer True if the upload ran at the MTU-capped low speed (stock bootloader), so the
* Success screen can offer a one-time OTAFIX upgrade tip for faster future updates.
*/
data class Success(val wasLowSpeedTransfer: Boolean = false) : FirmwareUpdateState
/** UF2 file is ready; waiting for the user to choose a save location (USB flow). */
data class AwaitingFileSave(val uf2Artifact: FirmwareArtifact, val fileName: String) : FirmwareUpdateState

View File

@@ -237,8 +237,9 @@ class FirmwareUpdateViewModel(
updateState = { _state.value = it },
)
when (_state.value) {
is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress)
when (val finalState = _state.value) {
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
@@ -325,8 +326,9 @@ class FirmwareUpdateViewModel(
)
tempFirmwareFile = updateArtifact ?: extractedFile
when (_state.value) {
is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress)
when (val finalState = _state.value) {
is FirmwareUpdateState.Success ->
verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer)
is FirmwareUpdateState.Error -> {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
@@ -355,7 +357,7 @@ class FirmwareUpdateViewModel(
}
}
private suspend fun verifyUpdateResult(address: String?) {
private suspend fun verifyUpdateResult(address: String?, wasLowSpeedTransfer: Boolean = false) {
_state.value = FirmwareUpdateState.Verifying
// Trigger a fresh connection attempt by MeshService using the original prefixed address
@@ -378,7 +380,7 @@ class FirmwareUpdateViewModel(
Logger.w { "Post-update verification timed out for $address" }
_state.value = FirmwareUpdateState.VerificationFailed
} else {
_state.value = FirmwareUpdateState.Success
_state.value = FirmwareUpdateState.Success(wasLowSpeedTransfer)
}
}

View File

@@ -351,6 +351,6 @@ class Esp32OtaUpdateHandler(
.getOrThrow()
Logger.i { "ESP32 OTA: Firmware stream completed" }
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success())
}
}

View File

@@ -208,7 +208,7 @@ class SecureDfuHandler(
)
completed = true
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success(wasLowSpeedTransfer = transport.isLowSpeedTransfer))
zipFile
} finally {
// Send ABORT if cancelled mid-transfer, then always clean up.

View File

@@ -129,7 +129,7 @@ class FirmwareUpdateIntegrationTest {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.Updating(ProgressState()))
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success())
null
}

View File

@@ -187,7 +187,7 @@ class FirmwareUpdateViewModelTest {
.calls {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success())
null
}

View File

@@ -197,7 +197,7 @@ class FirmwareUpdateViewModelFileTest {
.calls {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success())
null
}
@@ -257,7 +257,7 @@ class FirmwareUpdateViewModelFileTest {
.calls {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.Success)
updateState(FirmwareUpdateState.Success())
null
}