diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index bbfcf08919..7b23affd4a 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 56dd944633..84e153e434 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -627,6 +627,11 @@ Firmware Edition %1$s has ended. Return to standard Meshtastic firmware to restore normal features. Update firmware + Latest: %1$s + Installed: %1$s + Latest version: %1$s. The installed version is shown once the device restarts into update mode. + Bootloader is up to date + Nothing to upgrade. The firmware is reinstalled next, which restarts the device. 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. Couldn't copy the file to the device's drive. Make sure the drive is still connected and try again. The erase image list isn't available right now. Check your connection and try again, or use the web flasher at flasher.meshtastic.org. diff --git a/docs/en/user/firmware.md b/docs/en/user/firmware.md index 70573b9b21..14a9be9cab 100644 --- a/docs/en/user/firmware.md +++ b/docs/en/user/firmware.md @@ -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: diff --git a/feature/firmware/README.md b/feature/firmware/README.md index 4cedc772cd..b7b1b14dbb 100644 --- a/feature/firmware/README.md +++ b/feature/firmware/README.md @@ -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. diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt index 8eeb31b07a..08a7b76543 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt @@ -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 = {}, + ) } } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt index a67f85a402..14549b2582 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt @@ -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, diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 05ca499c7f..da9819805e 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -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)) + } + } } } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt index 39a2ae47e4..d08747f6eb 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt @@ -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%?") diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index b26f3eec38..a790ce71f9 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -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() diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt index baf8baebd5..4fed03042a 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@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:" diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt index b046382b7a..c84d8f9673 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt @@ -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 diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt index 34357d9b1d..c383e90033 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt @@ -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. */ diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt index e98a38136a..834bbf8f06 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt @@ -191,6 +191,7 @@ abstract class CommonMaintenanceVolumeTest { val accepted = assertIs(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 diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonUsbPassWriterTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonUsbPassWriterTest.kt index 9e8643f8b5..cc9d89e9fc 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonUsbPassWriterTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonUsbPassWriterTest.kt @@ -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) diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt index b452744d67..02cedb6a6f 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt @@ -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(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(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(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", + ) + } } diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index 3a146be33b..28cab78586 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -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(viewModel.state.value).maintenance.latestBootloader, + ) + viewModel.startBootloaderUpgrade() + runUntilSettled { viewModel.state.value is FirmwareUpdateState.AwaitingFileSave } + assertEquals( + UsbFileSaveStep.BootloaderUpgrade, + assertIs(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(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(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