feat(firmware): show installed vs latest bootloader before an upgrade (#7409)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-28 12:59:55 +00:00
1 parent 6426058ae2
commit 428d2a296b
16 files changed
+500 -9

No files matched your search

+5
View File
@@ -597,6 +597,11 @@ firmware
firmware_edition
firmware_event_ended_banner
firmware_event_ended_button
firmware_maintenance_bootloader_available
firmware_maintenance_bootloader_installed
firmware_maintenance_bootloader_latest_hint
firmware_maintenance_bootloader_up_to_date
firmware_maintenance_bootloader_up_to_date_hint
firmware_maintenance_cdc_unblock_failed
firmware_maintenance_copy_failed
firmware_maintenance_data_unavailable
@@ -627,6 +627,11 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_event_ended_banner">%1$s has ended. Return to standard Meshtastic firmware to restore normal features.</string>
<string name="firmware_event_ended_button">Update firmware</string>
<string name="firmware_maintenance_bootloader_available">Latest: %1$s</string>
<string name="firmware_maintenance_bootloader_installed">Installed: %1$s</string>
<string name="firmware_maintenance_bootloader_latest_hint">Latest version: %1$s. The installed version is shown once the device restarts into update mode.</string>
<string name="firmware_maintenance_bootloader_up_to_date">Bootloader is up to date</string>
<string name="firmware_maintenance_bootloader_up_to_date_hint">Nothing to upgrade. The firmware is reinstalled next, which restarts the device.</string>
<string name="firmware_maintenance_cdc_unblock_failed">The erase file was copied but the device didn't start erasing. Nothing has been changed yet. Unplug the device, double-press its reset button, and try again.</string>
<string name="firmware_maintenance_copy_failed">Couldn't copy the file to the device's drive. Make sure the drive is still connected and try again.</string>
<string name="firmware_maintenance_data_unavailable">The erase image list isn't available right now. Check your connection and try again, or use the web flasher at flasher.meshtastic.org.</string>
+2
View File
@@ -76,6 +76,8 @@ Both a USB erase and a bootloader upgrade write two files in turn, so you are as
The app reads `INFO_UF2.TXT` from the drive you select to confirm it really is the device's update drive and to identify the board before writing anything.
For a bootloader upgrade, the app also reads the installed bootloader version from `INFO_UF2.TXT` and shows it next to the latest release before writing anything. The running firmware doesn't report its bootloader, so the installed version appears only once the device has restarted into update mode, never on the firmware screen while connected. If the two match, the bootloader is left as it is and the app moves straight on to reinstalling the firmware. Otherwise choose **Upgrade bootloader** to write it, or **Skip** to reinstall the firmware without changing it.
On nRF52 the app must already know which Bluetooth stack your device uses before it starts, because it can't read the bootloader until the device has rebooted. If it can't confirm the stack, it refuses to erase and points you at the [Web Flasher](https://flasher.meshtastic.org) instead. In the Web Flasher, choosing the wrong Bluetooth stack can leave the radio recoverable only with a hardware programmer.
Once the drive is readable, how an nRF52 device is erased depends on its bootloader:
+3 -1
View File
@@ -89,6 +89,8 @@ Two runtime facts make the sketch path safety-critical, not just another UF2 wri
- **The nRF52 erase sketch is SoftDevice-version-specific.** Writing the S140 6.1.1 image to a 7.3.0 device (or vice versa) corrupts the SoftDevice with no on-device recovery. `MaintenanceUf2.kt` treats the mounted volume's own `INFO_UF2.TXT` `SoftDevice:` line as authoritative over the bundled hardware-catalog hint — the two must agree, or the app refuses rather than guessing (`EraseImageResolution.Conflict`). The sketch also blocks in `while (!Serial)` until a host asserts DTR, which is what `UsbPassWriter`'s CDC unblock step is for (`MaintenanceUf2.requiresCdcUnblock`).
- **OTAFIX bootloaders are resolved by `Board-ID`, not by build target or USB VID/PID** — both of the latter collide across multiple boards. `otafixUf2ForBoardId()` looks up the exact bootloader image for the `Board-ID:` line the volume reports; the Meshtastic build-target name is only ever used to decide whether to *offer* the action in the UI.
A bootloader upgrade stops at `FirmwareUpdateState.ReviewingBootloader` once the drive is read and before anything is downloaded: `parseUf2BootloaderVersion()` takes the installed version from the `UF2 Bootloader` line and `reviewBootloader()` sets it beside the manifest's `otafixReleaseTag`. An exact match reads as up to date and skips the bootloader write; anything else is only "different", since bench and vendor tags carry no order. The running firmware cannot report its bootloader (the app only sees the packed `0x000902` the bootloader leaves in `NRF_TIMER2->CC[0]`, the same for stock and every OTAFIX build), so this is the first point the app knows the installed version.
```mermaid
sequenceDiagram
participant App as Android App
@@ -123,7 +125,7 @@ A `FirmwareMaintenanceLock` (`:core:common`) is held for the duration of the seq
- `SecureDfuTransport.kt`: BLE transport layer for Secure DFU using Kable (control/data point characteristics, PRN flow control).
- `DfuZipParser.kt`: Parses Nordic DFU ZIP archives (manifest, init packet, firmware binary).
- `UsbUpdateHandler.kt`: Handles USB/UF2 firmware updates across platforms.
- `MaintenanceUf2.kt`: Pinned erase/OTAFIX image resolution, `INFO_UF2.TXT` parsing (Board-ID, SoftDevice, Factory-Erase family), the drive-vs-map SoftDevice resolution used to pick a safe erase sketch, and the bootloader-driven erase resolver that pre-empts it.
- `MaintenanceUf2.kt`: Pinned erase/OTAFIX image resolution, `INFO_UF2.TXT` parsing (bootloader version, Board-ID, SoftDevice, Factory-Erase family), the drive-vs-map SoftDevice resolution used to pick a safe erase sketch, and the bootloader-driven erase resolver that pre-empts it.
- `Uf2Header.kt`: UF2 block-header readers (first target address, family ID) that `FirmwareRetriever` checks a downloaded maintenance image against before it can be written.
- `UsbMaintenance.kt`: Pure gating (`usbMaintenanceGate`) and volume-inspection/image-choice types for the factory-erase and bootloader-upgrade actions.
- `UsbUpdateSupport.kt`: Sequences a maintenance pass (download → reboot to DFU → vet volume → write → confirm landed) and drives the two-pass state machine.
@@ -92,7 +92,43 @@ internal fun UsbMaintenanceCardPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.padding(24.dp)) {
UsbMaintenanceCard(deviceName = "RAK4631", onBootloaderUpgrade = {})
UsbMaintenanceCard(
deviceName = "RAK4631",
latestBootloader = "0.9.2-OTAFIX2.5",
onBootloaderUpgrade = {},
)
}
}
}
}
@PreviewLightDark
@Composable
internal fun BootloaderReviewPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
BootloaderReviewState(
versions = BootloaderVersions(installed = "0.9.2-OTAFIX2.3-BP1.5", available = "0.9.2-OTAFIX2.5"),
onUpgrade = {},
onSkip = {},
)
}
}
}
}
@PreviewLightDark
@Composable
internal fun BootloaderUpToDatePreview() {
AppTheme {
Surface {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
BootloaderReviewState(
versions = BootloaderVersions(installed = "0.9.2-OTAFIX2.5", available = "0.9.2-OTAFIX2.5"),
onUpgrade = {},
onSkip = {},
)
}
}
}
@@ -27,6 +27,9 @@ data class FirmwareUpdateActions(
/** Pick the device's UF2 volume for a maintenance pass, which vets the drive before writing to it. */
val onPickVolume: () -> Unit,
val onBootloaderUpgrade: () -> Unit,
val onConfirmBootloaderUpgrade: () -> Unit,
/** Leaves the bootloader as it is and moves on to reinstalling the firmware. */
val onSkipBootloaderUpgrade: () -> Unit,
val onConfirmLocalFile: () -> Unit,
val onDismissLocalFile: () -> Unit,
val onRetry: () -> Unit,
@@ -94,6 +94,11 @@ 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_maintenance_bootloader_available
import org.meshtastic.core.resources.firmware_maintenance_bootloader_installed
import org.meshtastic.core.resources.firmware_maintenance_bootloader_latest_hint
import org.meshtastic.core.resources.firmware_maintenance_bootloader_up_to_date
import org.meshtastic.core.resources.firmware_maintenance_bootloader_up_to_date_hint
import org.meshtastic.core.resources.firmware_maintenance_select_drive
import org.meshtastic.core.resources.firmware_maintenance_upgrade_bootloader_action
import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_text
@@ -150,8 +155,11 @@ import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.img_chirpy
import org.meshtastic.core.resources.img_hw_unknown
import org.meshtastic.core.resources.learn_more
import org.meshtastic.core.resources.next
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.skip
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Bluetooth
@@ -218,6 +226,8 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
onSaveFile = { fileName -> saveFileLauncher(fileName, UF2_MIME_TYPE) },
onPickVolume = volumePickerLauncher,
onBootloaderUpgrade = viewModel::startBootloaderUpgrade,
onConfirmBootloaderUpgrade = viewModel::confirmBootloaderUpgrade,
onSkipBootloaderUpgrade = viewModel::skipBootloaderUpgrade,
onConfirmLocalFile = viewModel::confirmLocalFirmwareFile,
onDismissLocalFile = viewModel::dismissLocalFirmwareFile,
onRetry = viewModel::checkForUpdates,
@@ -394,6 +404,9 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh
// ViewModel, so letting the screen sleep (and the ViewModel clear) would strand the device.
is FirmwareUpdateState.AwaitingFileSave -> state.step.isDestructive || state.retryMessage != null
// The device is sitting in update mode with the rest of the sequence queued in the ViewModel.
is FirmwareUpdateState.ReviewingBootloader -> true
else -> false
}
@@ -438,6 +451,13 @@ private fun FirmwareUpdateContent(
deviceWasWiped = state.deviceWasWiped,
)
is FirmwareUpdateState.ReviewingBootloader ->
BootloaderReviewState(
versions = state.versions,
onUpgrade = actions.onConfirmBootloaderUpgrade,
onSkip = actions.onSkipBootloaderUpgrade,
)
is FirmwareUpdateState.AwaitingFileSave ->
AwaitingFileSaveState(
state = state,
@@ -515,7 +535,11 @@ private fun ReadyState(
}
if (state.maintenance.showBootloaderUpgrade) {
UsbMaintenanceCard(deviceName = device.displayName, onBootloaderUpgrade = actions.onBootloaderUpgrade)
UsbMaintenanceCard(
deviceName = device.displayName,
latestBootloader = state.maintenance.latestBootloader,
onBootloaderUpgrade = actions.onBootloaderUpgrade,
)
Spacer(Modifier.height(16.dp))
}
@@ -884,7 +908,7 @@ private fun DeviceInfoCard(
* on.
*/
@Composable
internal fun UsbMaintenanceCard(deviceName: String, onBootloaderUpgrade: () -> Unit) {
internal fun UsbMaintenanceCard(deviceName: String, latestBootloader: String?, onBootloaderUpgrade: () -> Unit) {
var showUpgradeConfirmation by rememberSaveable { mutableStateOf(false) }
if (showUpgradeConfirmation) {
@@ -906,6 +930,76 @@ internal fun UsbMaintenanceCard(deviceName: String, onBootloaderUpgrade: () -> U
TextButton(onClick = { showUpgradeConfirmation = true }) {
Text(stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action))
}
latestBootloader?.let {
Text(
text = stringResource(Res.string.firmware_maintenance_bootloader_latest_hint, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
}
/**
* Installed against latest bootloader, read from the drive before an upgrade is written. The running firmware cannot
* report its bootloader, so this is the first point the app knows the installed version.
*/
@Composable
internal fun BootloaderReviewState(versions: BootloaderVersions, onUpgrade: () -> Unit, onSkip: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
if (versions.isCurrent) MeshtasticIcons.CheckCircle else MeshtasticIcons.Usb,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(
if (versions.isCurrent) {
Res.string.firmware_maintenance_bootloader_up_to_date
} else {
Res.string.firmware_maintenance_upgrade_confirm_title
},
),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Text(
stringResource(
Res.string.firmware_maintenance_bootloader_installed,
versions.installed ?: stringResource(Res.string.unknown),
),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
)
Text(
stringResource(Res.string.firmware_maintenance_bootloader_available, versions.available),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
if (versions.isCurrent) {
Text(
stringResource(Res.string.firmware_maintenance_bootloader_up_to_date_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onSkip) { Text(stringResource(Res.string.next)) }
} else {
Row(horizontalArrangement = spacedBy(16.dp)) {
OutlinedButton(onClick = onSkip) { Text(stringResource(Res.string.skip)) }
Button(onClick = onUpgrade) {
Text(stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action))
}
}
}
}
}
@@ -110,6 +110,12 @@ sealed interface FirmwareUpdateState {
val step: UsbFileSaveStep = UsbFileSaveStep.Firmware,
val retryMessage: UiText? = null,
) : FirmwareUpdateState
/**
* The device's update drive has been read for a bootloader upgrade and nothing has been written yet. The user
* either upgrades or skips straight to reinstalling the firmware, which is also what restarts the device.
*/
data class ReviewingBootloader(val versions: BootloaderVersions) : FirmwareUpdateState
}
private val FORMAT_ARG_REGEX = Regex(":?\\s*%1\\\$d%?")
@@ -187,6 +187,9 @@ class FirmwareUpdateViewModel(
*/
private var maintenanceWriteJob: Job? = null
/** The drive read for [FirmwareUpdateState.ReviewingBootloader], written to if the user confirms the upgrade. */
private var reviewedVolume: CommonUri? = null
/**
* True once an erase or bootloader image has been written, which is the point the device stops having a working
* application. From then on failures re-offer the pass instead of surfacing a dead end.
@@ -262,6 +265,7 @@ class FirmwareUpdateViewModel(
private fun endMaintenanceSequence() {
maintenanceWriteJob = null
pendingUsbPasses = emptyList()
reviewedVolume = null
destructiveWriteDone = false
maintenanceHardware = null
releaseMaintenanceLease()
@@ -689,6 +693,55 @@ class FirmwareUpdateViewModel(
if (pass.step != currentState.step) return
val hardware = maintenanceHardware ?: return
if (pass.step == UsbFileSaveStep.BootloaderUpgrade) {
reviewBootloaderPass(pass, treeUri)
} else {
launchPassWrite(pass, treeUri, hardware)
}
}
/** Writes the reviewed bootloader upgrade to the drive the review read. */
@Suppress("ReturnCount") // preconditions guarding a destructive write
fun confirmBootloaderUpgrade() {
if (_state.value !is FirmwareUpdateState.ReviewingBootloader) return
val pass = pendingUsbPasses.firstOrNull()?.takeIf { it.step == UsbFileSaveStep.BootloaderUpgrade } ?: return
val treeUri = reviewedVolume ?: return
val hardware = maintenanceHardware ?: return
reviewedVolume = null
launchPassWrite(pass, treeUri, hardware)
}
/** Moves on to reinstalling the firmware without writing the bootloader, so nothing destructive has happened. */
fun skipBootloaderUpgrade() {
if (_state.value !is FirmwareUpdateState.ReviewingBootloader) return
val pass = pendingUsbPasses.firstOrNull()?.takeIf { it.step == UsbFileSaveStep.BootloaderUpgrade } ?: return
reviewedVolume = null
viewModelScope.launch { advancePastPass(pass, written = false) }
}
private fun reviewBootloaderPass(pass: UsbFileSavePass, treeUri: CommonUri) {
maintenanceWriteJob =
viewModelScope.launch {
try {
when (val review = usbPassWriter(portsBefore = emptySet()).review(treeUri)) {
is BootloaderReview.Refused -> reofferOrFail(pass, usbMaintenanceRefusalMessage(review.reason))
is BootloaderReview.Ready -> {
reviewedVolume = treeUri
_state.value = FirmwareUpdateState.ReviewingBootloader(review.versions)
}
}
} catch (e: CancellationException) {
endMaintenanceSequence()
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Reading the bootloader drive failed" }
reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_failed))
}
}
}
private fun launchPassWrite(pass: UsbFileSavePass, treeUri: CommonUri, hardware: DeviceHardware) {
maintenanceWriteJob =
viewModelScope.launch {
try {
@@ -728,8 +781,8 @@ class FirmwareUpdateViewModel(
reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_cdc_unblock_failed))
}
private suspend fun advancePastPass(pass: UsbFileSavePass) {
if (pass.step.isDestructive) destructiveWriteDone = true
private suspend fun advancePastPass(pass: UsbFileSavePass, written: Boolean = true) {
if (written && pass.step.isDestructive) destructiveWriteDone = true
pendingUsbPasses = pendingUsbPasses.drop(1)
val next = pendingUsbPasses.firstOrNull()
@@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package org.meshtastic.feature.firmware
import org.meshtastic.core.model.DeviceHardware
@@ -185,6 +187,25 @@ internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text
?.trim()
?.takeIf { it.isNotEmpty() }
/**
* Extracts the installed bootloader version from the contents of a UF2 bootloader's `INFO_UF2.TXT`.
*
* `ghostfat.c` writes the bootloader's git tag as the first token after `UF2 Bootloader` (e.g. `0.9.2-OTAFIX2.5`),
* followed by submodule versions. The 0.4.x line also carries a `Ver:` line, read only when the first line is absent.
* Returns `null` when neither line yields a token.
*/
internal fun parseUf2BootloaderVersion(infoUf2Text: String): String? =
listOf(UF2_BOOTLOADER_PREFIX, UF2_VER_PREFIX).firstNotNullOfOrNull { prefix ->
infoUf2Text
.lineSequence()
.map { it.trim() }
.firstOrNull { it.startsWith(prefix, ignoreCase = true) }
?.drop(prefix.length)
?.trim()
?.substringBefore(' ')
?.takeIf { it.isNotEmpty() }
}
/**
* Extracts the installed SoftDevice from the contents of a UF2 bootloader's `INFO_UF2.TXT`.
*
@@ -288,6 +309,10 @@ internal fun resolveNrfEraseImage(
/** The file every Adafruit-family UF2 bootloader exposes on its mass-storage volume. */
internal const val INFO_UF2_FILE_NAME = "INFO_UF2.TXT"
private const val UF2_BOOTLOADER_PREFIX = "UF2 Bootloader "
private const val UF2_VER_PREFIX = "Ver:"
private const val UF2_BOARD_ID_PREFIX = "Board-ID:"
private const val UF2_SOFTDEVICE_PREFIX = "SoftDevice:"
@@ -148,13 +148,28 @@ internal fun usbMaintenanceRefusalMessage(reason: UsbMaintenanceRefusal): UiText
* @property eraseRefusal Non-null when erase is shown but cannot run; the reason is displayed and the action disabled.
* @property showBootloaderUpgrade Whether a bootloader-upgrade action is offered. Absent (not refused) when no image is
* mapped for the board — an unmapped board is a coverage gap, not a safety decision the user can act on.
* @property latestBootloader The OTAFIX release the upgrade installs, shown next to the action. The installed version
* is unknowable here: only the mounted drive reports it.
*/
data class UsbMaintenanceGate(
val show: Boolean = false,
val eraseRefusal: UsbMaintenanceRefusal? = null,
val showBootloaderUpgrade: Boolean = false,
val latestBootloader: String? = null,
)
/**
* The bootloader a mounted drive reports next to the one the upgrade would install.
*
* @property installed From the drive's `INFO_UF2.TXT`, or `null` when it reports none.
* @property available [MaintenanceUf2Manifest.otafixReleaseTag].
*/
data class BootloaderVersions(val installed: String?, val available: String) {
/** Exact match only: bench and vendor builds carry tags with no order to trust, so anything else just differs. */
val isCurrent: Boolean
get() = installed != null && installed == available
}
/**
* Decides which maintenance actions are available for [hardware] on [updateMethod].
*
@@ -188,12 +203,14 @@ internal fun usbMaintenanceGate(
else -> UsbMaintenanceRefusal.MaintenanceDataUnavailable
}
// nRF-only: RP2040 boards run no Adafruit bootloader, so OTAFIX does not apply. This is a visibility hint only
// — which image gets written is decided later from the Board-ID the drive reports.
val showBootloaderUpgrade = hardware.isNrf52Arc && otafixSupportsTarget(manifest, hardware.effectiveTarget)
return UsbMaintenanceGate(
show = true,
eraseRefusal = eraseRefusal,
// nRF-only: RP2040 boards run no Adafruit bootloader, so OTAFIX does not apply. This is a visibility hint only
// — which image gets written is decided later from the Board-ID the drive reports.
showBootloaderUpgrade = hardware.isNrf52Arc && otafixSupportsTarget(manifest, hardware.effectiveTarget),
showBootloaderUpgrade = showBootloaderUpgrade,
latestBootloader = manifest.otafixReleaseTag.takeIf { showBootloaderUpgrade && it.isNotBlank() },
)
}
@@ -206,11 +223,14 @@ internal fun usbMaintenanceGate(
* @property factoryEraseFamily The UF2 family ID the bootloader will consume as a factory-erase command (its
* `Factory-Erase:` line), when it advertises one. `null` on every bootloader shipped before OTAFIX PR #41 — those
* silently ignore the file, so `null` means "use the SoftDevice-specific sketch", never "refuse".
* @property bootloaderVersion The installed bootloader's version, from the `UF2 Bootloader` line. The running firmware
* cannot report it, so this drive is the only place the app learns it.
*/
internal data class MaintenanceVolume(
val boardId: String,
val softDevice: SoftDeviceVariant?,
val factoryEraseFamily: Long? = null,
val bootloaderVersion: String? = null,
)
/** Outcome of vetting a user-picked volume before anything is written to it. */
@@ -243,10 +263,32 @@ internal suspend fun inspectMaintenanceVolume(treeUri: CommonUri, fileHandler: F
boardId = boardId,
softDevice = parseUf2SoftDevice(info),
factoryEraseFamily = parseUf2FactoryEraseFamily(info),
bootloaderVersion = parseUf2BootloaderVersion(info),
),
)
}
/** What the drive says before a bootloader upgrade is written, or why the upgrade cannot run on it. */
internal sealed interface BootloaderReview {
data class Ready(val versions: BootloaderVersions) : BootloaderReview
data class Refused(val reason: UsbMaintenanceRefusal) : BootloaderReview
}
/**
* Compares the bootloader [volume] reports against [manifest]'s release, once the drive has been read and before
* anything is downloaded or written.
*
* Refuses an unrecognized Board-ID here, with the same outcome [chooseMaintenanceImage] would reach at write time, so
* the user is never shown an upgrade that cannot run.
*/
internal fun reviewBootloader(manifest: MaintenanceUf2Manifest, volume: MaintenanceVolume): BootloaderReview =
if (otafixUf2ForBoardId(manifest, volume.boardId) == null) {
BootloaderReview.Refused(UsbMaintenanceRefusal.UnknownBoardId)
} else {
BootloaderReview.Ready(BootloaderVersions(volume.bootloaderVersion, manifest.otafixReleaseTag))
}
/** Which image to write, or why not. */
internal sealed interface MaintenanceImageChoice {
data class Resolved(val asset: MaintenanceUf2) : MaintenanceImageChoice
@@ -255,6 +255,16 @@ internal class UsbPassWriter(
return UsbPassResult.Written
}
/**
* Reads [treeUri] for a bootloader upgrade and compares what it reports against the release. Writes nothing;
* [write] vets the volume again once the user confirms.
*/
suspend fun review(treeUri: CommonUri): BootloaderReview =
when (val inspection = inspectMaintenanceVolume(treeUri, fileHandler)) {
is VolumeInspection.Rejected -> BootloaderReview.Refused(inspection.reason)
is VolumeInspection.Accepted -> reviewBootloader(maintenanceUf2Repository.getSnapshot(), inspection.volume)
}
/** Either the image to write, or the result to return instead. */
private sealed interface ImageResolution {
/** @property requiresCdcUnblock Carried from [MaintenanceUf2.requiresCdcUnblock]; always false for firmware. */
@@ -191,6 +191,7 @@ abstract class CommonMaintenanceVolumeTest {
val accepted = assertIs<VolumeInspection.Accepted>(result)
assertEquals("WisBlock-RAK4631-Board", accepted.volume.boardId)
assertEquals(SoftDeviceVariant.S140_6_1_1, accepted.volume.softDevice)
assertEquals("0.4.3", accepted.volume.bootloaderVersion)
}
@Test
@@ -158,6 +158,23 @@ abstract class CommonUsbPassWriterTest {
assertTrue(bootloader.unblockCalls.isEmpty())
}
@Test
fun `reviewing a bootloader upgrade reads the drive and writes nothing`() = runTest {
val h = harness(sketchInfo)
val review = h.writer.review(treeUri)
assertEquals(BootloaderReview.Ready(BootloaderVersions("0.4.3", "0.9.2-OTAFIX2.3-BP1.5")), review)
assertTrue(h.written.isEmpty(), "no image is fetched until the user confirms")
}
@Test
fun `reviewing refuses a drive that is not a bootloader volume`() = runTest {
val h = harness("Model: Something\r\n")
assertEquals(BootloaderReview.Refused(UsbMaintenanceRefusal.NotABootloaderVolume), h.writer.review(treeUri))
}
@Test
fun `a bootloader self-update never has its cdc port opened`() = runTest {
val h = harness(sketchInfo)
@@ -23,6 +23,7 @@ import org.meshtastic.core.model.SoftDeviceVariant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -868,4 +869,96 @@ class UsbMaintenanceGateTest {
assertNull(uf2FamilyId(ByteArray(UF2_BLOCK_BYTES)), "Zeroed bytes carry no UF2 magic")
assertNull(uf2FamilyId(ByteArray(32)), "A short payload cannot hold a UF2 block")
}
// ── Installed bootloader version against the release ─────────────────────
/**
* The `INFO_UF2.TXT` text embedded in the released `update-wismesh_tag_bootloader-0.9.2-OTAFIX2.5_nosd.uf2`,
* extracted from its UF2 payload. The running bootloader appends its `SoftDevice:` line to this at boot.
*/
private val wismeshTagOtafix25Info =
"UF2 Bootloader 0.9.2-OTAFIX2.5 lib/nrfx (v3.14.0) lib/tinyusb (0.21.0-435-g3898a1df4) " +
"lib/uf2 (heads/master)\r\n" +
"Model: WisMesh Tag\r\nBoard-ID: WisMesh-Tag\r\nDate: Sep 8 2026\r\n" +
"Factory-Erase: UF2 family 0x4D455348\r\n"
private fun volumeFrom(info: String) = MaintenanceVolume(
boardId = assertNotNull(parseUf2BoardId(info)),
softDevice = parseUf2SoftDevice(info),
bootloaderVersion = parseUf2BootloaderVersion(info),
)
@Test
fun `bootloader version is the first token of the uf2 bootloader line on every known vintage`() {
assertEquals("0.4.3", parseUf2BootloaderVersion(rak4631StockInfo))
assertEquals("0.9.2-OTAFIX2.2-BP1.3", parseUf2BootloaderVersion(rak4631OtafixInfo))
assertEquals("0.9.2-dirty", parseUf2BootloaderVersion(seeedL1Info), "stock Seeed builds carry git's suffix")
assertEquals("0.9.2-OTAFIX2.3-BP1.6", parseUf2BootloaderVersion(rakOtafixEraseInfo))
assertEquals("0.9.2-OTAFIX2.5", parseUf2BootloaderVersion(wismeshTagOtafix25Info))
}
@Test
fun `bootloader version falls back to the ver line and is null when neither line is present`() {
assertEquals("0.4.3", parseUf2BootloaderVersion("Board-ID: WisBlock-RAK4631-Board\r\nVer: 0.4.3\r\n"))
assertNull(parseUf2BootloaderVersion("Board-ID: WisBlock-RAK4631-Board\r\n"))
assertNull(parseUf2BootloaderVersion("UF2 Bootloader \r\nBoard-ID: X\r\n"), "an empty version is no version")
assertNull(parseUf2BootloaderVersion(""))
}
@Test
fun `the released bootloader reads as current against its own release tag`() {
val manifest = testManifest.copy(otafixReleaseTag = "0.9.2-OTAFIX2.5")
val review = assertIs<BootloaderReview.Ready>(reviewBootloader(manifest, volumeFrom(wismeshTagOtafix25Info)))
assertEquals(BootloaderVersions(installed = "0.9.2-OTAFIX2.5", available = "0.9.2-OTAFIX2.5"), review.versions)
assertTrue(review.versions.isCurrent)
}
@Test
fun `any other installed version reads as not current without claiming an order`() {
// testManifest is on BP1.5; BP1.6 is a bench build newer than it, and still only "different".
for (info in listOf(rak4631StockInfo, rak4631OtafixInfo, rakOtafixEraseInfo, wismeshTagOtafix25Info)) {
val review = assertIs<BootloaderReview.Ready>(reviewBootloader(testManifest, volumeFrom(info)))
assertFalse(review.versions.isCurrent, "installed ${review.versions.installed}")
assertEquals("0.9.2-OTAFIX2.3-BP1.5", review.versions.available)
}
}
@Test
fun `a drive reporting no version is never current`() {
val volume = MaintenanceVolume(boardId = "WisMesh-Tag", softDevice = null, bootloaderVersion = null)
val review = assertIs<BootloaderReview.Ready>(reviewBootloader(testManifest, volume))
assertNull(review.versions.installed)
assertFalse(BootloaderVersions(installed = null, available = "").isCurrent, "two unknowns are not a match")
assertFalse(review.versions.isCurrent)
}
@Test
fun `review refuses an unrecognized board id before anything is downloaded`() {
val volume = MaintenanceVolume(boardId = "SomeOtherBoard-v9", softDevice = null, bootloaderVersion = "0.9.2")
assertEquals(
BootloaderReview.Refused(UsbMaintenanceRefusal.UnknownBoardId),
reviewBootloader(testManifest, volume),
)
}
@Test
fun `the gate carries the latest bootloader only where the upgrade is offered`() {
val offered = maintenanceGate(testManifest, nrf(), FirmwareUpdateMethod.Usb, hasRelease = true)
assertEquals("0.9.2-OTAFIX2.3-BP1.5", offered.latestBootloader)
val unsupported = maintenanceGate(testManifest, nrf(target = "wio-sdk-wm1110"), FirmwareUpdateMethod.Usb, true)
assertFalse(unsupported.showBootloaderUpgrade)
assertNull(unsupported.latestBootloader)
assertNull(maintenanceGate(testManifest, rp2040(), FirmwareUpdateMethod.Usb, true).latestBootloader)
assertNull(
maintenanceGate(MaintenanceUf2Manifest(), nrf(), FirmwareUpdateMethod.Usb, true).latestBootloader,
"no manifest, no version to show",
)
}
}
@@ -35,6 +35,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
@@ -51,6 +52,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.EraseImageEntry
import org.meshtastic.core.model.MaintenanceUf2EraseSet
import org.meshtastic.core.model.MaintenanceUf2Manifest
import org.meshtastic.core.model.OtafixAssetEntry
import org.meshtastic.core.model.SoftDeviceVariant
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -1030,6 +1032,101 @@ class FirmwareUpdateViewModelFileTest {
verifySuspend { usbManager.ensureSerialPermission(any()) }
}
/** Starts a bootloader upgrade on a RAK4631 whose drive reports [installed], and picks that drive once. */
private suspend fun TestScope.reviewBootloaderOn(installed: String) {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1))
everySuspend { maintenanceUf2Repository.getSnapshot() } returns
testMaintenanceUf2Manifest.copy(
otafixReleaseTag = "0.9.2-OTAFIX2.5",
otafixBase = "https://example.invalid/otafix",
otafixByBoardId =
mapOf("WisBlock-RAK4631-Board" to OtafixAssetEntry("wiscore_rak4631_board", "0".repeat(64))),
otafixSupportedTargets = listOf("rak4631"),
)
everySuspend { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } returns
FirmwareArtifact(uri = CommonUri.parse("file:///tmp/firmware.uf2"), fileName = "firmware.uf2")
everySuspend { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) } returns
FirmwareArtifact(uri = CommonUri.parse("file:///tmp/bootloader.uf2"), fileName = "bootloader.uf2")
everySuspend { fileHandler.isRemovableDestination(any()) } returns true
everySuspend { fileHandler.readSiblingText(any(), any()) } returns
"UF2 Bootloader $installed lib/nrfx (v3.14.0)\r\nBoard-ID: WisBlock-RAK4631-Board\r\n" +
"SoftDevice: S140 6.1.1\r\n"
everySuspend { fileHandler.createDocumentInTree(any(), any(), any()) } returns
CommonUri.parse("content://tree/1234-5678%3A/document/bootloader.uf2")
everySuspend { fileHandler.copyToUri(any(), any()) } returns 1024L
every { usbManager.deviceDetachFlow() } returns flowOf(Unit)
everySuspend { usbManager.serialPortKeys() } returns emptySet()
viewModel = createViewModel()
advanceUntilIdle()
assertEquals(
"0.9.2-OTAFIX2.5",
assertIs<FirmwareUpdateState.Ready>(viewModel.state.value).maintenance.latestBootloader,
)
viewModel.startBootloaderUpgrade()
runUntilSettled { viewModel.state.value is FirmwareUpdateState.AwaitingFileSave }
assertEquals(
UsbFileSaveStep.BootloaderUpgrade,
assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value).step,
)
viewModel.writeMaintenancePass(CommonUri.parse("content://tree/1234-5678%3A"))
runUntilSettled { viewModel.state.value is FirmwareUpdateState.ReviewingBootloader }
}
@Test
fun `an up to date bootloader is left alone and the sequence moves on to the firmware`() = runTest {
reviewBootloaderOn(installed = "0.9.2-OTAFIX2.5")
val review = assertIs<FirmwareUpdateState.ReviewingBootloader>(viewModel.state.value)
assertTrue(review.versions.isCurrent)
viewModel.skipBootloaderUpgrade()
runUntilSettled {
(viewModel.state.value as? FirmwareUpdateState.AwaitingFileSave)?.step == UsbFileSaveStep.Firmware
}
verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) }
verifySuspend(mode = VerifyMode.not) { fileHandler.createDocumentInTree(any(), any(), any()) }
assertTrue(
radioOperationLock.activeOperations.contains(RadioOperation.FirmwareMaintenance),
"the device is still in update mode, so the sequence keeps the radio until the firmware is back",
)
}
@Test
fun `a different bootloader is written only once the user confirms`() = runTest {
reviewBootloaderOn(installed = "0.9.2-OTAFIX2.3-BP1.5")
val review = assertIs<FirmwareUpdateState.ReviewingBootloader>(viewModel.state.value)
assertEquals("0.9.2-OTAFIX2.3-BP1.5", review.versions.installed)
assertFalse(review.versions.isCurrent)
verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) }
viewModel.confirmBootloaderUpgrade()
runUntilSettled {
(viewModel.state.value as? FirmwareUpdateState.AwaitingFileSave)?.step == UsbFileSaveStep.Firmware
}
verifySuspend(mode = exactly(1)) { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) }
verifySuspend { fileHandler.createDocumentInTree(any(), "bootloader.uf2", any()) }
}
@Test
fun `cancelling at the bootloader review releases the maintenance lock`() = runTest {
reviewBootloaderOn(installed = "0.9.2-OTAFIX2.5")
viewModel.cancelUpdate()
advanceUntilIdle()
assertFalse(radioOperationLock.activeOperations.contains(RadioOperation.FirmwareMaintenance))
viewModel.confirmBootloaderUpgrade()
advanceUntilIdle()
verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) }
}
@Test
fun `a granted USB permission preflight reconnects explicitly instead of waiting on auto-recovery`() = runTest {
// Auto-recovery's attach trigger fires while the permission dialog is still up and never retries on