From 7656d8198ebbf19a21da96c6d76300f611f9f153 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 07:33:11 -0500 Subject: [PATCH 01/19] feat(firmware): add fail-closed SoftDevice metadata and pinned maintenance UF2s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for USB/UF2 factory erase and OTAFIX bootloader upgrade. The erase images are linked for a specific application start address (0x26000 for S140 6.1.1, 0x27000 for 7.3.0) and the UF2 bootloader's write guard begins at MBR_SIZE, so the SoftDevice region is writable — flashing the wrong variant erases a SoftDevice page and needs SWD or serial DFU to recover. The variant therefore has to be known, never inferred. - Add SoftDeviceVariant + SoftDeviceVariantEntry as a separate array in the bundled quirks asset. BootloaderOtaQuirk is left untouched: it is an advisory warning that may safely fail open, and one record carrying both postures would invite a future edit that relaxes the wrong one. - Resolve the variant target-strictly. hwModel alone is not unique (hwModel 94 HELTEC_MESH_POCKET has two nRF52840 targets), so the device-reported target must appear in the row. Asset absent, asset malformed, model unmapped and target unrecognised all converge on null, and callers refuse on null. - Wire type is String? rather than the enum because the shared Json sets coerceInputValues = true, which would coerce a typo'd enum to a default. - Map 31 nRF52840 models, generated from the firmware repo's board ldscripts rather than hand-typed. MESH_TRACKER_X1 (128) is 7.3.0 — the web flasher's allowlist omits it and serves it the 6.1.1 image. THINKNODE_M8 (130) has no firmware variant on master and is present-but-unmapped so its refusal is deliberate and greppable. - Pin the erase and OTAFIX images by commit/tag with SHA-256 digests, plus a UF2 first-target-address cross-check that catches a swapped URL/digest row — the one authoring mistake a digest alone cannot catch. - Reject DFU packages with no 'application' image. A bootloader-only package has imageCount == 1 so it passed the existing multi-image warning and was uploaded with START_DFU's type hard-coded to APPLICATION and sd/bl sizes zeroed; only validateNrf52LocalFirmware's suffix check stood in the way. Co-Authored-By: Claude Opus 5 --- .../assets/device_bootloader_ota_quirks.json | 252 +++++++++++++++++- .../DeviceHardwareRepositoryImpl.kt | 43 ++- .../database/entity/DeviceHardwareEntity.kt | 2 + .../core/model/BootloaderOtaQuirk.kt | 58 +++- .../meshtastic/core/model/DeviceHardware.kt | 16 ++ .../feature/firmware/MaintenanceUf2.kt | 165 ++++++++++++ .../feature/firmware/UsbMaintenance.kt | 115 ++++++++ .../feature/firmware/ota/dfu/DfuZipParser.kt | 14 +- 8 files changed, 655 insertions(+), 10 deletions(-) create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt diff --git a/androidApp/src/main/assets/device_bootloader_ota_quirks.json b/androidApp/src/main/assets/device_bootloader_ota_quirks.json index 960c631010f..dca2060a323 100644 --- a/androidApp/src/main/assets/device_bootloader_ota_quirks.json +++ b/androidApp/src/main/assets/device_bootloader_ota_quirks.json @@ -18,5 +18,255 @@ "requiresBootloaderUpgradeForOta": true, "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" } + ], + "softDeviceVariants": [ + { + "hwModel": 7, + "hwModelSlug": "T_ECHO", + "platformioTargets": [ + "t-echo" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 9, + "hwModelSlug": "RAK4631", + "platformioTargets": [ + "rak4631" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 18, + "hwModelSlug": "NANO_G2_ULTRA", + "platformioTargets": [ + "nano-g2-ultra" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 21, + "hwModelSlug": "WIO_WM1110", + "platformioTargets": [ + "wio-tracker-wm1110" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 22, + "hwModelSlug": "WISMESH_HUB", + "platformioTargets": [ + "rak2560" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 29, + "hwModelSlug": "CANARYONE", + "platformioTargets": [ + "canaryone" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 33, + "hwModelSlug": "T_ECHO_PLUS", + "platformioTargets": [ + "t-echo-plus" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 63, + "hwModelSlug": "NRF52_PROMICRO_DIY", + "platformioTargets": [ + "nrf52_promicro_diy_tcxo" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 69, + "hwModelSlug": "HELTEC_MESH_NODE_T114", + "platformioTargets": [ + "heltec-mesh-node-t114" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 71, + "hwModelSlug": "TRACKER_T1000_E", + "platformioTargets": [ + "tracker-t1000-e" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 84, + "hwModelSlug": "WISMESH_TAP", + "platformioTargets": [ + "rak_wismeshtap" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 88, + "hwModelSlug": "XIAO_NRF52_KIT", + "platformioTargets": [ + "seeed_xiao_nrf52840_kit" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 89, + "hwModelSlug": "THINKNODE_M1", + "platformioTargets": [ + "thinknode_m1" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 93, + "hwModelSlug": "MUZI_BASE", + "platformioTargets": [ + "muzi-base" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 94, + "hwModelSlug": "HELTEC_MESH_POCKET", + "platformioTargets": [ + "heltec-mesh-pocket-10000", + "heltec-mesh-pocket-5000" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 95, + "hwModelSlug": "SEEED_SOLAR_NODE", + "platformioTargets": [ + "seeed_solar_node" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 96, + "hwModelSlug": "NOMADSTAR_METEOR_PRO", + "platformioTargets": [ + "rak4631_nomadstar_meteor_pro" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 99, + "hwModelSlug": "SEEED_WIO_TRACKER_L1", + "platformioTargets": [ + "seeed_wio_tracker_L1" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 100, + "hwModelSlug": "SEEED_WIO_TRACKER_L1_EINK", + "platformioTargets": [ + "seeed_wio_tracker_L1_eink" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 101, + "hwModelSlug": "MUZI_R1_NEO", + "platformioTargets": [ + "r1-neo" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 105, + "hwModelSlug": "WISMESH_TAG", + "platformioTargets": [ + "rak_wismeshtag" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 108, + "hwModelSlug": "HELTEC_MESH_SOLAR", + "platformioTargets": [ + "heltec-mesh-solar" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 109, + "hwModelSlug": "T_ECHO_LITE", + "platformioTargets": [ + "t-echo-lite" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 115, + "hwModelSlug": "THINKNODE_M3", + "platformioTargets": [ + "thinknode_m3" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 117, + "hwModelSlug": "RAK3401", + "platformioTargets": [ + "rak3401-1watt" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 119, + "hwModelSlug": "THINKNODE_M4", + "platformioTargets": [ + "thinknode_m4" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 120, + "hwModelSlug": "THINKNODE_M6", + "platformioTargets": [ + "thinknode_m6" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 127, + "hwModelSlug": "HELTEC_MESH_NODE_T096", + "platformioTargets": [ + "heltec-mesh-node-t096" + ], + "softDevice": "6.1.1" + }, + { + "hwModel": 128, + "hwModelSlug": "MESH_TRACKER_X1", + "platformioTargets": [ + "seeed_mesh_tracker_X1" + ], + "softDevice": "7.3.0" + }, + { + "hwModel": 130, + "hwModelSlug": "THINKNODE_M8", + "platformioTargets": [ + "thinknode_m8" + ] + }, + { + "hwModel": 133, + "hwModelSlug": "HELTEC_MESH_NODE_T1", + "platformioTargets": [ + "heltec-mesh-node-t1" + ], + "softDevice": "6.1.1" + } ] -} \ No newline at end of file +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 56a695ec01a..832e75eb594 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -36,6 +36,8 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.BootloaderOtaQuirksResponse import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.NetworkDeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant +import org.meshtastic.core.model.SoftDeviceVariantEntry import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.DeviceHardwareRemoteDataSource import org.meshtastic.core.repository.DeviceHardwareRepository @@ -185,8 +187,34 @@ class DeviceHardwareRepositoryImpl( /** Resolves entities into a [DeviceHardware] domain model with quirk application. */ private fun resolveHardware(hwModel: Int, entities: List, target: String?): DeviceHardware? { val matched = disambiguate(entities, target) - val quirks = loadQuirks() - return applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target) + val asset = loadQuirksAsset() + val withQuirk = applyBootloaderQuirk(hwModel, matched?.asExternalModel(), asset.devices, target) + return applySoftDeviceVariant(hwModel, withQuirk, asset.softDeviceVariants, target) + } + + /** + * Overlays the SoftDevice variant, requiring the reported target to be one this model is actually mapped for. + * + * Deliberately stricter than [applyBootloaderQuirk] and deliberately not routed through [disambiguate]: both of + * those fall back to "close enough" (`firstOrNull()`), which is right for an advisory warning and wrong here. + * `hwModel` is not unique — hwModel 94 (`HELTEC_MESH_POCKET`) has two nRF52840 targets — and a plausible-but-wrong + * variant writes an erase image into the SoftDevice. Every unresolvable case (asset absent, asset malformed, + * model unmapped, reported target not in the row) therefore lands on `null`, and callers must refuse on `null`. + */ + private fun applySoftDeviceVariant( + hwModel: Int, + base: DeviceHardware?, + entries: List, + reportedTarget: String?, + ): DeviceHardware? = base?.let { hw -> + // applyBootloaderQuirk has already overwritten platformioTarget with the reported target when one was given. + val effectiveTarget = reportedTarget?.takeIf { it.isNotBlank() } ?: hw.platformioTarget + val matched = + entries.firstOrNull { entry -> + entry.hwModel == hwModel && + entry.platformioTargets.any { it.equals(effectiveTarget, ignoreCase = true) } + } + hw.copy(softDeviceVariant = SoftDeviceVariant.fromWire(matched?.softDevice)) } private fun disambiguate(entities: List, target: String?): DeviceHardwareEntity? = @@ -205,14 +233,15 @@ class DeviceHardwareRepositoryImpl( private fun DeviceHardwareEntity.isStale(): Boolean = isIncomplete() || (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS - // Quirks are best-effort: swallow any parse/IO error and fall back to "no quirks" rather than failing hardware - // lookup. - private fun loadQuirks(): List = runCatching { - assetReader.decode("device_bootloader_ota_quirks.json", json)?.devices + // Quirks are best-effort: swallow any parse/IO error and fall back to an empty asset rather than failing hardware + // lookup. Safe for the advisory bootloader warning, and safe for the SoftDevice map too because an empty map + // resolves to a null variant, which refuses. + private fun loadQuirksAsset(): BootloaderOtaQuirksResponse = runCatching { + assetReader.decode("device_bootloader_ota_quirks.json", json) } .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } } .getOrNull() - .orEmpty() + ?: BootloaderOtaQuirksResponse() private fun applyBootloaderQuirk( hwModel: Int, diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt index a8f75e76bee..4c4112432dd 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt @@ -72,8 +72,10 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware( partitionScheme = partitionScheme, platformioTarget = platformioTarget, requiresDfu = requiresDfu, + // Quirk/overlay fields are applied at read time by DeviceHardwareRepositoryImpl and never persisted. requiresBootloaderUpgradeForOta = null, bootloaderInfoUrl = null, + softDeviceVariant = null, supportLevel = supportLevel, tags = tags, ) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt index 5bf90acf5b5..7cc43e7878b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt @@ -20,7 +20,63 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** Envelope of the bundled `device_bootloader_ota_quirks.json` asset. */ -@Serializable data class BootloaderOtaQuirksResponse(val devices: List = emptyList()) +@Serializable +data class BootloaderOtaQuirksResponse( + val devices: List = emptyList(), + /** + * Per-model SoftDevice variants. Kept as a separate array from [devices] on purpose: [BootloaderOtaQuirk] is an + * advisory warning that may safely fail open, while this map gates a destructive flash and must fail closed. One + * record carrying both postures would invite a future edit that relaxes the wrong one. + */ + @SerialName("softDeviceVariants") val softDeviceVariants: List = emptyList(), +) + +/** + * The Nordic SoftDevice an nRF52840 board's firmware is linked against. Static hardware fact, derived from the board's + * `build.arduino.ldscript` in `meshtastic/firmware` (`nrf52840_s140_v6.ld` / `_v7.ld`). + * + * Load-bearing for factory erase: the erase images are linked for a specific application start address (0x26000 for + * 6.1.1, 0x27000 for 7.3.0) and the UF2 bootloader's write guard begins at `MBR_SIZE` (0x1000), so the SoftDevice + * region is writable. Flashing the 6.1.1 image to a 7.3.0 device erases the SoftDevice's last page, which needs SWD or + * serial DFU to recover. Never infer this value — an unresolved variant must refuse the operation. + */ +enum class SoftDeviceVariant { + S140_6_1_1, + S140_7_3_0, + ; + + companion object { + /** + * Maps the wire value to a variant, returning `null` for anything unrecognized. + * + * The asset carries a `String?` rather than this enum because the shared `Json` sets `coerceInputValues = true`, + * which would silently coerce a typo'd enum value to a default. An unrecognized string lands on `null` instead, + * and `null` refuses. + */ + fun fromWire(value: String?): SoftDeviceVariant? = when (value) { + "6.1.1" -> S140_6_1_1 + "7.3.0" -> S140_7_3_0 + else -> null + } + } +} + +/** + * One hardware model's SoftDevice mapping. + * + * @property platformioTargets Every catalog target for this [hwModel]. Resolution requires the device-reported target + * to appear here, because `hwModel` alone is not unique — hwModel 94 (`HELTEC_MESH_POCKET`) has two nRF52840 targets + * — and borrowing a sibling's variant is exactly the mistake that corrupts a SoftDevice. + * @property softDevice Wire value (`"6.1.1"` / `"7.3.0"`), or absent when the variant is unknown. An entry present with + * no `softDevice` records a deliberate, greppable refusal rather than an oversight. + */ +@Serializable +data class SoftDeviceVariantEntry( + @SerialName("hwModel") val hwModel: Int, + @SerialName("hwModelSlug") val hwModelSlug: String? = null, + @SerialName("platformioTargets") val platformioTargets: List = emptyList(), + @SerialName("softDevice") val softDevice: String? = null, +) @Serializable data class BootloaderOtaQuirk( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt index e51e6262103..b17fb6012f7 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt @@ -38,10 +38,26 @@ data class DeviceHardware( val requiresBootloaderUpgradeForOta: Boolean? = null, /** Optional URL pointing to documentation for upgrading the bootloader. */ val bootloaderInfoUrl: String? = null, + /** + * The SoftDevice this board's firmware is linked against, or `null` when it could not be resolved. A read-time + * overlay like [requiresBootloaderUpgradeForOta] — never persisted. + * + * `null` means "unknown", never "the common one": destructive flows must refuse rather than assume. See + * [SoftDeviceVariant]. + */ + val softDeviceVariant: SoftDeviceVariant? = null, val supportLevel: Int? = null, val tags: List? = null, ) { /** Returns true if the device architecture is ESP32-based. */ val isEsp32Arc: Boolean get() = architecture.startsWith("esp32", ignoreCase = true) + + /** Returns true if the device architecture is nRF52-based. */ + val isNrf52Arc: Boolean + get() = architecture.startsWith("nrf52", ignoreCase = true) + + /** Returns true if the device architecture is RP2040-based. */ + val isRp2040Arc: Boolean + get() = architecture.startsWith("rp2040", ignoreCase = true) } 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 new file mode 100644 index 00000000000..67d30b04415 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt @@ -0,0 +1,165 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant + +/** + * A pinned, content-verified UF2 image used by a maintenance flow (factory erase, bootloader upgrade). + * + * Unlike release firmware — which `FirmwareRetriever` resolves against a versioned release folder — these images have no + * versioned upstream. `meshtastic/nrf52_factory_erase` has cut no GitHub releases, so the flasher serves them from a + * mutable `public/uf2/` path. Pinning the URL to a commit and verifying [sha256] before any write is what turns that + * mutable path back into an immutable one. + * + * @property expectedFirstTargetAddress For nRF erase images, the flash address the UF2's first block writes to. Checked + * against the resolved [SoftDeviceVariant] before the image is offered, because a swapped URL/digest row is the one + * authoring mistake a digest alone cannot catch — and the mistake that corrupts a SoftDevice. Null when the image's + * address carries no such invariant (RP2040, bootloader self-updates). + */ +internal data class MaintenanceUf2( + val url: String, + val fileName: String, + val sha256: String, + val expectedFirstTargetAddress: Long? = null, +) { + init { + // downloadFile interpolates fileName straight into a temp path; these are compile-time constants, but assert it + // so a future edit can't introduce traversal. + require(fileName.isNotBlank() && fileName.none { it == '/' || it == '\\' } && !fileName.contains("..")) { + "Unsafe maintenance UF2 filename: $fileName" + } + } +} + +/** + * Commit-pinned base for the factory-erase images checked into `meshtastic/web-flasher` at `public/uf2/`. + * + * The blobs have not changed since this commit (2024-09-03), so the pin is cheap to hold. Built from + * `meshtastic/nrf52_factory_erase` (GPL-3.0). + */ +private const val ERASE_UF2_BASE = + "https://raw.githubusercontent.com/meshtastic/web-flasher/0e353b5d0756c9a1b76f53be78e948fafc1ebd8a/public/uf2" + +/** Release-pinned base for the OTAFIX bootloader self-update images (`oltaco/…_OTAFIX`, MIT). */ +private const val OTAFIX_RELEASE_TAG = "0.9.2-OTAFIX2.2-BP1.3" + +private const val OTAFIX_BASE = + "https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/$OTAFIX_RELEASE_TAG" + +/** S140 6.1.1 application start — the address `nrf_erase2.uf2` is linked for. */ +internal const val APP_START_S140_6_1_1 = 0x26000L + +/** S140 7.3.0 application start — the address `nrf_erase_sd7_3.uf2` is linked for. */ +internal const val APP_START_S140_7_3_0 = 0x27000L + +private val ERASE_S140_6_1_1 = MaintenanceUf2( + url = "$ERASE_UF2_BASE/nrf_erase2.uf2", + fileName = "nrf_erase2.uf2", + sha256 = "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe", + expectedFirstTargetAddress = APP_START_S140_6_1_1, +) + +private val ERASE_S140_7_3_0 = MaintenanceUf2( + url = "$ERASE_UF2_BASE/nrf_erase_sd7_3.uf2", + fileName = "nrf_erase_sd7_3.uf2", + sha256 = "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f", + expectedFirstTargetAddress = APP_START_S140_7_3_0, +) + +private val PICO_ERASE = MaintenanceUf2( + url = "$ERASE_UF2_BASE/pico_erase.uf2", + fileName = "pico_erase.uf2", + sha256 = "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853", +) + +/** + * OTAFIX bootloader self-update images, keyed by Meshtastic `platformioTarget`. + * + * Deliberately sparse. The OTAFIX assets are named after *their* PlatformIO board names, which do not correspond to + * Meshtastic's by any mechanical rule: `heltec_t114` vs `heltec-mesh-node-t114`, `thinknode_m1` vs `ThinkNode-M1`, + * `t1000_e` vs `tracker-t1000-e`. Worse, six distinct Meshtastic products (WISMESH Hub/Tap/Tag, Nomadstar Meteor Pro, + * RAK3401, RAK4631) all build against the single `wiscore_rak4631` board, so a board-level match would offer one + * product's bootloader to five others. + * + * A bootloader built for different hardware is as unrecoverable as a wrong-SoftDevice erase, so entries are added only + * where the correspondence is unambiguous *and* the pairing has been validated on real hardware. Everything else + * resolves to null and the upgrade action is simply not offered — see `usbMaintenanceGate`. + */ +private val OTAFIX_BY_TARGET: Map = mapOf( + "rak4631" to + MaintenanceUf2( + url = "$OTAFIX_BASE/update-wiscore_rak4631_board_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", + fileName = "update-wiscore_rak4631_board_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", + sha256 = "3509c8b01296bc6473acf5a9422f9aec857a5bbb47e80000f9b98e15b046e46a", + ), + "tracker-t1000-e" to + MaintenanceUf2( + url = "$OTAFIX_BASE/update-t1000_e_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", + fileName = "update-t1000_e_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", + sha256 = "c1dd30cce0f250eb7ad21e8c065cef6692b53e7efcba0578371bedcfd2493cc9", + ), +) + +/** + * The factory-erase image for [hardware], or `null` when none can be resolved safely. + * + * nRF52840 requires a resolved [DeviceHardware.softDeviceVariant]: the two images are linked for different application + * start addresses, and the UF2 bootloader's write guard begins at `MBR_SIZE`, so the wrong one erases a SoftDevice page. + * There is deliberately no default branch. + */ +internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when { + hardware.isRp2040Arc -> PICO_ERASE + + hardware.isNrf52Arc -> + when (hardware.softDeviceVariant) { + SoftDeviceVariant.S140_6_1_1 -> ERASE_S140_6_1_1 + SoftDeviceVariant.S140_7_3_0 -> ERASE_S140_7_3_0 + null -> null + } + + else -> null +} + +/** The OTAFIX bootloader self-update image for [platformioTarget], or `null` when this board is not mapped. */ +internal fun otafixUf2For(platformioTarget: String): MaintenanceUf2? = OTAFIX_BY_TARGET[platformioTarget] + +/** UF2 block size, per the UF2 specification. */ +private const val UF2_BLOCK_SIZE = 512 + +private const val UF2_MAGIC_START0 = 0x0A324655 +private const val UF2_TARGET_ADDR_OFFSET = 12 + +/** + * Reads the target flash address of the first UF2 block in [bytes], or `null` when the payload is not a UF2 image. + * + * Used to cross-check a pinned erase image against the resolved SoftDevice variant before it is written. + */ +internal fun uf2FirstTargetAddress(bytes: ByteArray): Long? { + if (bytes.size < UF2_BLOCK_SIZE) return null + if (readLittleEndianUInt32(bytes, 0) != UF2_MAGIC_START0.toLong()) return null + return readLittleEndianUInt32(bytes, UF2_TARGET_ADDR_OFFSET) +} + +private fun readLittleEndianUInt32(bytes: ByteArray, offset: Int): Long { + var value = 0L + for (i in 3 downTo 0) { + value = (value shl 8) or (bytes[offset + i].toLong() and 0xFF) + } + return value +} 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 new file mode 100644 index 00000000000..273785b489e --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt @@ -0,0 +1,115 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +import org.meshtastic.core.model.DeviceHardware + +/** + * Which leg of a multi-pass USB/UF2 sequence a file-save prompt belongs to. + * + * Also the recomposition key for the instruction dialog in `AwaitingFileSaveState`. Every sequence shipped today is + * (FactoryErase | BootloaderUpgrade) → Firmware, so the key always changes between passes; a future sequence with two + * consecutive identical steps would need a pass index added here. + */ +enum class UsbFileSaveStep { + /** The release firmware image — the only pass in a plain update, and the last pass of every maintenance sequence. */ + Firmware, + + /** The factory-erase image, which wipes the internal filesystem and re-enters UF2 DFU. */ + FactoryErase, + + /** An OTAFIX bootloader self-update image. */ + BootloaderUpgrade, + ; + + /** + * True when writing this image destroys the device's application, making the *next* pass mandatory rather than + * optional. Drives back-navigation gating and the "no abort edge" retry behaviour. + */ + val isDestructive: Boolean + get() = this != Firmware +} + +/** A maintenance operation the user can start from the Ready state. */ +enum class UsbMaintenanceRequest { + FactoryErase, + BootloaderUpgrade, +} + +/** Why a maintenance action cannot run. Surfaced as explanatory copy rather than silently hiding the action. */ +enum class UsbMaintenanceRefusal { + /** + * The device's SoftDevice variant could not be resolved, so no erase image can be chosen safely. Covers an absent or + * malformed metadata asset, an unmapped hardware model, and a device reporting a target we have no mapping for. + */ + UnknownSoftDevice, + + /** The architecture has no UF2 erase path at all (ESP32, portduino). */ + UnsupportedArchitecture, + + /** No release firmware is selected, so there would be nothing to re-flash after erasing. */ + NoFirmwareRelease, +} + +/** + * Which maintenance affordances the Ready state should show, precomputed by the ViewModel. + * + * Follows the `showBootloaderWarning` precedent: the screen stays dumb and the decision stays unit-testable. + * + * @property show Whether the maintenance section appears at all. + * @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. + */ +data class UsbMaintenanceGate( + val show: Boolean = false, + val eraseRefusal: UsbMaintenanceRefusal? = null, + val showBootloaderUpgrade: Boolean = false, +) + +/** + * Decides which maintenance actions are available for [hardware] on [updateMethod]. + * + * Pure and total: every refusal is represented, and nothing here can fall back to a default erase image. + * + * @param hasRelease Whether a firmware release is selected. Erasing without one would strand the device with no + * application, so the whole section is hidden. + */ +internal fun usbMaintenanceGate( + hardware: DeviceHardware, + updateMethod: FirmwareUpdateMethod, + hasRelease: Boolean, +): UsbMaintenanceGate { + val uf2Architecture = hardware.isNrf52Arc || hardware.isRp2040Arc + if (updateMethod !is FirmwareUpdateMethod.Usb || !uf2Architecture || !hasRelease) { + return UsbMaintenanceGate(show = false) + } + + val eraseRefusal = + when { + eraseUf2For(hardware) != null -> null + hardware.isNrf52Arc -> UsbMaintenanceRefusal.UnknownSoftDevice + else -> UsbMaintenanceRefusal.UnsupportedArchitecture + } + + return UsbMaintenanceGate( + show = true, + eraseRefusal = eraseRefusal, + // nRF-only: RP2040 boards run no Adafruit bootloader, so OTAFIX does not apply. + showBootloaderUpgrade = hardware.isNrf52Arc && otafixUf2For(hardware.effectiveTarget) != null, + ) +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt index 9c9fc0c8ac1..506feac177f 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt @@ -48,8 +48,20 @@ internal fun parseDfuZipEntries(entries: Map): DfuZipPackage throw DfuException.InvalidPackage("Failed to parse manifest.json: $detail") } + // Require an application image rather than accepting whatever primaryEntry promotes. A bootloader- or + // SoftDevice-only package has imageCount == 1, so it slipped past the warning below and was uploaded with + // START_DFU's image type hard-coded to APPLICATION and the sd/bl sizes zeroed — the only thing standing between a + // renamed OTAFIX .zip and that path is validateNrf52LocalFirmware's "-ota.zip" suffix check. #5916 left multi-image + // *sequencing* out of scope; this only refuses what was never flashable. val entry = - manifest.manifest.primaryEntry ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json") + manifest.manifest.application + ?: manifest.manifest.primaryEntry?.let { found -> + throw DfuException.InvalidPackage( + "manifest.json declares no 'application' image (found '${found.binFile}'). SoftDevice and " + + "bootloader packages must not be flashed as an application image.", + ) + } + ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json") if (manifest.manifest.imageCount > 1) { Logger.w { From e149c07566ecb447d56561d924d198b9a63cc529 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 07:46:19 -0500 Subject: [PATCH 02/19] feat(firmware): verify pinned maintenance images and gate erase availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the retrieval and decision layer for USB/UF2 factory erase and OTAFIX bootloader upgrade. No user-visible flow yet — the mechanism lands next. - FirmwareRetriever.retrieveMaintenanceUf2 is the module's first absolute-URL entry point. Verification is terminal: a digest or UF2 target-address mismatch deletes the download and returns null with no release-zip fallback, because "some other file with the right name" is exactly the failure being guarded against when the payload is destructive. - usbMaintenanceGate is a pure, total decision: nRF52840/RP2040 on a serial connection with a release selected. An unresolved SoftDevice keeps the action visible but refused so the reason can be explained; an unmapped OTAFIX board hides it, since that is a coverage gap rather than something a user can act on. There is no branch that can select a default erase image. - OTAFIX is mapped for rak4631 and tracker-t1000-e only. The OTAFIX assets are named after their own PlatformIO boards, which match Meshtastic's by no mechanical rule, and six distinct products (WISMESH Hub/Tap/Tag, Nomadstar Meteor Pro, RAK3401, RAK4631) share the wiscore_rak4631 board — so a board-level match would offer one product's bootloader to five others. A bootloader built for other hardware is as unrecoverable as a wrong-SoftDevice erase, so entries are added only where the pairing is unambiguous and hardware-validatable. All 14 digests are known; widening is a data edit. - FirmwareFileHandler gains isRemovableDestination and isDestinationReadable. These close the loop on a destructive write: SAF hands back whatever the user picked, and copyToUri will happily write a UF2 to Downloads. Only the maintenance flow consults them — the single-pass update path is unchanged, since its worst case is "nothing happened, replug". Co-Authored-By: Claude Opus 5 --- .../firmware/AndroidFirmwareFileHandler.kt | 32 +++ .../feature/firmware/FirmwareFileHandler.kt | 20 ++ .../feature/firmware/FirmwareRetriever.kt | 48 +++++ .../feature/firmware/MaintenanceUf2.kt | 8 +- .../firmware/CommonFirmwareRetrieverTest.kt | 95 ++++++++- .../firmware/UsbMaintenanceGateTest.kt | 195 ++++++++++++++++++ .../firmware/ota/dfu/DfuZipParserTest.kt | 55 +++++ .../firmware/JvmFirmwareFileHandler.kt | 11 + 8 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 6ec2bb46497..54838008dcd 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware import android.content.Context +import android.provider.DocumentsContract import android.provider.OpenableColumns import co.touchlab.kermit.Logger import com.eygraber.uri.toAndroidUri @@ -45,6 +46,12 @@ import java.util.zip.ZipInputStream private const val DOWNLOAD_BUFFER_SIZE = 8192 +/** SAF provider for physical volumes; the only one a UF2 bootloader drive can appear under. */ +private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" + +/** SAF volume id for internal shared storage — never a removable drive. */ +private const val PRIMARY_VOLUME_ID = "primary" + /** * Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and * extracting specific files from Zip archives. @@ -318,6 +325,31 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean = org.meshtastic.feature.firmware.isValidFirmwareFile(filename, target, fileExtension) + /** + * Accepts only a Storage Access Framework document on a non-primary external volume. + * + * `com.android.externalstorage.documents` document ids are `:`, where internal shared storage is + * always `primary`. A mounted USB mass-storage volume — which is what a UF2 bootloader drive is — gets its own + * volume id. Every other provider (Downloads, Drive, MediaStore) is therefore rejected, which is the point: those + * are exactly where a mis-tap sends the image. + */ + override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) { + runCatching { + val androidUri = destinationUri.toAndroidUri() + if (androidUri.authority != EXTERNAL_STORAGE_AUTHORITY) return@runCatching false + val documentId = DocumentsContract.getDocumentId(androidUri) + val volumeId = documentId.substringBefore(':', missingDelimiterValue = "") + volumeId.isNotBlank() && !volumeId.equals(PRIMARY_VOLUME_ID, ignoreCase = true) + } + .onFailure { Logger.w(it) { "Could not classify destination volume for $destinationUri" } } + .getOrDefault(false) + } + + override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) { + runCatching { context.contentResolver.openInputStream(destinationUri.toAndroidUri())?.use { true } == true } + .getOrDefault(false) + } + override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long = withContext(ioDispatcher) { val inputStream = diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index fafa48104eb..a9272982bf2 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -70,6 +70,26 @@ interface FirmwareFileHandler { /** Copy [source] to the platform URI [destinationUri], returning the number of bytes written. */ suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long + /** + * Best-effort check that [destinationUri] names a location on removable storage — i.e. plausibly a mounted UF2 + * bootloader drive rather than internal storage. + * + * Used to reject the Downloads mistake *before* writing, which matters only once a write is destructive: in a + * multi-pass maintenance sequence a mis-saved pass leaves the device with no application, and nothing else in the + * flow can tell a landed write from a lost one. The plain single-pass update path deliberately does not consult + * this — its worst case is "nothing happened, replug". + */ + suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean + + /** + * True when [destinationUri] can still be opened for reading. + * + * The success signal for a UF2 write is inverted: a UF2 bootloader consumes the image, reboots, and its volume + * disappears, so a destination that is *still* readable afterwards means the bytes landed somewhere persistent — + * the wrong place. Used as the tiebreaker when the detach broadcast is missed. + */ + suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean + // ── Zip / extraction ───────────────────────────────────────────────── /** diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 6a3ae777148..a02653e6302 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -76,6 +76,54 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { internalFileExtension = ".uf2", ) + /** + * Download a pinned maintenance image (factory erase, bootloader upgrade) and verify it before returning. + * + * The module's only absolute-URL entry point. Unlike release artifacts, these images have no versioned upstream — + * `nrf52_factory_erase` has cut no releases — so the URL is commit/tag-pinned and the content is the real contract. + * + * A digest or address mismatch is **terminal**: the file is deleted and `null` is returned with no fallback. The + * release-zip fallback the other retrievers use would be actively wrong here, because the payload is destructive + * and "some other file with the right name" is precisely the failure being guarded against. + * + * @return The verified [FirmwareArtifact], or `null` when the download failed or verification did not pass. + */ + internal suspend fun retrieveMaintenanceUf2(asset: MaintenanceUf2, onProgress: (Float) -> Unit): FirmwareArtifact? { + val artifact = + try { + fileHandler.downloadFile(asset.url, asset.fileName, onProgress) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Maintenance image download failed: ${asset.fileName}" } + null + } ?: return null + + val bytes = fileHandler.readBytes(artifact) + val actualDigest = FirmwareHashUtil.bytesToHex(FirmwareHashUtil.calculateSha256Bytes(bytes)) + if (!actualDigest.equals(asset.sha256, ignoreCase = true)) { + Logger.e { "Maintenance image ${asset.fileName} digest mismatch (expected ${asset.sha256}, got $actualDigest)" } + fileHandler.deleteFile(artifact) + return null + } + + val expectedAddress = asset.expectedFirstTargetAddress + if (expectedAddress != null) { + val actualAddress = uf2FirstTargetAddress(bytes) + if (actualAddress != expectedAddress) { + // The digest proved which file arrived; this proves the pinned row itself isn't mismatched — a swapped + // URL/digest pair is the one authoring error a digest cannot catch, and the one that erases a SoftDevice. + Logger.e { + "Maintenance image ${asset.fileName} targets ${actualAddress?.toString(16)}, " + + "expected ${expectedAddress.toString(16)} — refusing to write" + } + fileHandler.deleteFile(artifact) + return null + } + } + + Logger.i { "Maintenance image ${asset.fileName} verified (${bytes.size} bytes)" } + return artifact + } + /** * Download the ESP32 OTA firmware binary. Tries in order: * 1. `.mt.json` manifest resolution (2.7.17+) — the authoritative `app0` partition image. 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 67d30b04415..877e0c3f3e8 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 @@ -140,10 +140,12 @@ internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when { internal fun otafixUf2For(platformioTarget: String): MaintenanceUf2? = OTAFIX_BY_TARGET[platformioTarget] /** UF2 block size, per the UF2 specification. */ -private const val UF2_BLOCK_SIZE = 512 +internal const val UF2_BLOCK_BYTES = 512 + +/** Byte offset of `targetAddr` within a UF2 block header. */ +internal const val UF2_TARGET_ADDR_OFFSET = 12 private const val UF2_MAGIC_START0 = 0x0A324655 -private const val UF2_TARGET_ADDR_OFFSET = 12 /** * Reads the target flash address of the first UF2 block in [bytes], or `null` when the payload is not a UF2 image. @@ -151,7 +153,7 @@ private const val UF2_TARGET_ADDR_OFFSET = 12 * Used to cross-check a pinned erase image against the resolved SoftDevice variant before it is written. */ internal fun uf2FirstTargetAddress(bytes: ByteArray): Long? { - if (bytes.size < UF2_BLOCK_SIZE) return null + if (bytes.size < UF2_BLOCK_BYTES) return null if (readLittleEndianUInt32(bytes, 0) != UF2_MAGIC_START0.toLong()) return null return readLittleEndianUInt32(bytes, UF2_TARGET_ADDR_OFFSET) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt index bbd47310295..59d05d357eb 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt @@ -448,6 +448,90 @@ abstract class CommonFirmwareRetrieverTest { assertEquals("firmware-pico-2.5.0.uf2", result.fileName) } + // ----------------------------------------------------------------------- + // Pinned maintenance images (factory erase / bootloader upgrade) + // ----------------------------------------------------------------------- + + /** Builds a single valid UF2 block whose payload target address is [targetAddress]. */ + private fun uf2Block(targetAddress: Long): ByteArray { + val block = ByteArray(UF2_BLOCK_BYTES) + fun putLe32(offset: Int, value: Long) { + for (i in 0 until 4) { + block[offset + i] = ((value shr (8 * i)) and 0xFF).toByte() + } + } + putLe32(0, 0x0A324655L) // magicStart0 + putLe32(UF2_TARGET_ADDR_OFFSET, targetAddress) + return block + } + + private fun maintenanceAsset(payload: ByteArray, expectedAddress: Long?) = MaintenanceUf2( + url = "https://example.com/uf2/nrf_erase_test.uf2", + fileName = "nrf_erase_test.uf2", + sha256 = FirmwareHashUtil.bytesToHex(FirmwareHashUtil.calculateSha256Bytes(payload)), + expectedFirstTargetAddress = expectedAddress, + ) + + @Test + fun `maintenance uf2 with matching digest and target address is returned`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val payload = uf2Block(APP_START_S140_7_3_0) + val asset = maintenanceAsset(payload, APP_START_S140_7_3_0) + handler.existingUrls.add(asset.url) + handler.fileBytes[asset.fileName] = payload + + val result = retriever.retrieveMaintenanceUf2(asset) {} + + assertNotNull(result, "A verified maintenance image should be returned") + assertEquals(asset.fileName, result.fileName) + assertTrue(handler.deletedFiles.isEmpty(), "A verified image must not be deleted") + } + + @Test + fun `maintenance uf2 digest mismatch is terminal and deletes the download`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val payload = uf2Block(APP_START_S140_7_3_0) + val asset = maintenanceAsset(payload, APP_START_S140_7_3_0).copy(sha256 = "00".repeat(32)) + handler.existingUrls.add(asset.url) + handler.fileBytes[asset.fileName] = payload + + val result = retriever.retrieveMaintenanceUf2(asset) {} + + assertNull(result, "A digest mismatch must not yield an artifact") + assertEquals(1, handler.deletedFiles.size, "The rejected download must be deleted") + } + + @Test + fun `maintenance uf2 with wrong target address is rejected even when the digest matches`() = runTest { + // A swapped URL/digest row: the file is intact and matches its own digest, but it is linked for the other + // SoftDevice. Writing it would erase a SoftDevice page. + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val payload = uf2Block(APP_START_S140_6_1_1) + val asset = maintenanceAsset(payload, APP_START_S140_7_3_0) + handler.existingUrls.add(asset.url) + handler.fileBytes[asset.fileName] = payload + + val result = retriever.retrieveMaintenanceUf2(asset) {} + + assertNull(result, "A target-address mismatch must not yield an artifact") + assertEquals(1, handler.deletedFiles.size, "The rejected download must be deleted") + } + + @Test + fun `maintenance uf2 skips the address check when the asset declares no expected address`() = runTest { + val handler = FakeFirmwareFileHandler() + val retriever = FirmwareRetriever(handler) + val payload = uf2Block(0x10000000L) + val asset = maintenanceAsset(payload, expectedAddress = null) + handler.existingUrls.add(asset.url) + handler.fileBytes[asset.fileName] = payload + + assertNotNull(retriever.retrieveMaintenanceUf2(asset) {}, "No expected address means no address check") + } + // ----------------------------------------------------------------------- // Test infrastructure // ----------------------------------------------------------------------- @@ -547,8 +631,17 @@ abstract class CommonFirmwareRetrieverTest { override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map = emptyMap() - override suspend fun deleteFile(file: FirmwareArtifact) {} + /** Artifacts passed to [deleteFile], so verification-failure paths can assert cleanup happened. */ + val deletedFiles = mutableListOf() + + override suspend fun deleteFile(file: FirmwareArtifact) { + deletedFiles.add(file) + } override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long = 0L + + override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = true + + override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = false } } 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 new file mode 100644 index 00000000000..ae8bda2ad9d --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt @@ -0,0 +1,195 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests the pure availability gate and pinned-asset resolvers. + * + * These are the safety-critical decisions for factory erase, so they live in a pure function with no fakes: the + * dangerous direction (writing a wrong-SoftDevice erase image) cannot be tested on hardware without destroying a + * device, which makes table-driven unit coverage of the refusal logic the only real verification available. + * + * Concrete (not a `Common*Test` base) because nothing here touches `CommonUri`, so it needs no platform subclass and + * cannot silently contribute zero tests. + */ +class UsbMaintenanceGateTest { + + private fun nrf( + variant: SoftDeviceVariant? = SoftDeviceVariant.S140_6_1_1, + target: String = "rak4631", + slug: String = "RAK4631", + ) = DeviceHardware( + hwModelSlug = slug, + platformioTarget = target, + architecture = "nrf52840", + softDeviceVariant = variant, + ) + + private fun rp2040(target: String = "pico") = + DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = target, architecture = "rp2040") + + private fun esp32() = + DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3") + + // ── Architecture and transport gating (R11) ────────────────────────────── + + @Test + fun `gate is shown for nrf52840 over usb with a release`() { + val gate = usbMaintenanceGate(nrf(), FirmwareUpdateMethod.Usb, hasRelease = true) + + assertTrue(gate.show, "nRF52840 over USB should offer maintenance") + assertNull(gate.eraseRefusal, "A resolved SoftDevice must not refuse") + } + + @Test + fun `gate is shown for rp2040 over usb and never refuses on softdevice`() { + val gate = usbMaintenanceGate(rp2040(), FirmwareUpdateMethod.Usb, hasRelease = true) + + assertTrue(gate.show, "RP2040 over USB should offer maintenance") + assertNull(gate.eraseRefusal, "RP2040 has no SoftDevice to resolve") + assertFalse(gate.showBootloaderUpgrade, "OTAFIX is nRF-only") + } + + @Test + fun `gate is hidden for esp32 even over usb`() { + assertFalse(usbMaintenanceGate(esp32(), FirmwareUpdateMethod.Usb, hasRelease = true).show) + } + + @Test + fun `gate is hidden for every non-usb transport`() { + listOf(FirmwareUpdateMethod.Ble, FirmwareUpdateMethod.Wifi, FirmwareUpdateMethod.Unknown).forEach { method -> + assertFalse( + usbMaintenanceGate(nrf(), method, hasRelease = true).show, + "Maintenance must not be offered over $method — the flow needs the UF2 mass-storage drive", + ) + } + } + + @Test + fun `gate is hidden without a release because there would be nothing to reflash`() { + assertFalse(usbMaintenanceGate(nrf(), FirmwareUpdateMethod.Usb, hasRelease = false).show) + } + + // ── Fail-closed SoftDevice refusal (R4) ────────────────────────────────── + + @Test + fun `unresolved softdevice shows the action but refuses it`() { + val gate = usbMaintenanceGate(nrf(variant = null), FirmwareUpdateMethod.Usb, hasRelease = true) + + assertTrue(gate.show, "The action stays visible so the refusal can be explained") + assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, gate.eraseRefusal) + } + + @Test + fun `no erase image is resolved for an unresolved softdevice`() { + assertNull(eraseUf2For(nrf(variant = null)), "An unknown variant must never fall back to a default image") + } + + @Test + fun `each softdevice variant resolves to its own image and target address`() { + val six = eraseUf2For(nrf(variant = SoftDeviceVariant.S140_6_1_1)) + val seven = eraseUf2For(nrf(variant = SoftDeviceVariant.S140_7_3_0)) + + assertNotNull(six) + assertNotNull(seven) + assertEquals("nrf_erase2.uf2", six.fileName) + assertEquals("nrf_erase_sd7_3.uf2", seven.fileName) + assertEquals(APP_START_S140_6_1_1, six.expectedFirstTargetAddress) + assertEquals(APP_START_S140_7_3_0, seven.expectedFirstTargetAddress) + assertTrue(six.sha256 != seven.sha256, "The two variants must not share a digest") + } + + @Test + fun `esp32 resolves no erase image`() { + assertNull(eraseUf2For(esp32())) + } + + @Test + fun `rp2040 resolves the pico erase image with no address invariant`() { + val asset = eraseUf2For(rp2040()) + + assertNotNull(asset) + assertEquals("pico_erase.uf2", asset.fileName) + assertNull(asset.expectedFirstTargetAddress, "RP2040 carries no variant-to-address invariant") + } + + // ── OTAFIX bootloader image resolution (R5) ────────────────────────────── + + @Test + fun `otafix is offered only for mapped boards`() { + assertTrue( + usbMaintenanceGate(nrf(target = "rak4631"), FirmwareUpdateMethod.Usb, hasRelease = true) + .showBootloaderUpgrade, + ) + assertFalse( + usbMaintenanceGate(nrf(target = "heltec-mesh-node-t114"), FirmwareUpdateMethod.Usb, hasRelease = true) + .showBootloaderUpgrade, + "An unmapped board hides the action rather than guessing a bootloader built for other hardware", + ) + } + + @Test + fun `otafix images are board specific`() { + val rak = otafixUf2For("rak4631") + val t1000 = otafixUf2For("tracker-t1000-e") + + assertNotNull(rak) + assertNotNull(t1000) + assertTrue(rak.fileName.contains("wiscore_rak4631_board")) + assertTrue(t1000.fileName.contains("t1000_e")) + assertTrue(rak.sha256 != t1000.sha256, "Distinct boards must not share a bootloader digest") + } + + @Test + fun `otafix rejects boards that merely share the rak4631 build target`() { + // WISMESH Hub/Tap/Tag, Nomadstar Meteor Pro and RAK3401 all build against the wiscore_rak4631 board, but they + // are different products. Matching at board level would offer one product's bootloader to the others. + listOf("rak2560", "rak_wismeshtap", "rak_wismeshtag", "rak4631_nomadstar_meteor_pro", "rak3401-1watt") + .forEach { target -> + assertNull(otafixUf2For(target), "$target must not resolve the RAK4631 bootloader") + } + } + + // ── UF2 header parsing (R8) ────────────────────────────────────────────── + + @Test + fun `uf2 target address is read from the first block`() { + val block = ByteArray(UF2_BLOCK_BYTES) + listOf(0x55, 0x46, 0x32, 0x0A).forEachIndexed { i, b -> block[i] = b.toByte() } + block[UF2_TARGET_ADDR_OFFSET] = 0x00 + block[UF2_TARGET_ADDR_OFFSET + 1] = 0x70 + block[UF2_TARGET_ADDR_OFFSET + 2] = 0x02 + block[UF2_TARGET_ADDR_OFFSET + 3] = 0x00 + + assertEquals(APP_START_S140_7_3_0, uf2FirstTargetAddress(block)) + } + + @Test + fun `non-uf2 payloads yield no target address`() { + assertNull(uf2FirstTargetAddress(ByteArray(UF2_BLOCK_BYTES)), "Zeroed bytes carry no UF2 magic") + assertNull(uf2FirstTargetAddress(ByteArray(16)), "A short payload cannot hold a UF2 block") + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt index f76f2f0b2c3..9fde2a29b42 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt @@ -51,6 +51,61 @@ class DfuZipParserTest { assertTrue(packageResult.initPacket.contentEquals(byteArrayOf(0x04, 0x05))) } + @Test + fun failsWhenManifestDeclaresOnlySoftDeviceBootloader() { + // An OTAFIX bootloader package. imageCount == 1, so it slipped past the multi-image warning and was uploaded + // with START_DFU's image type hard-coded to APPLICATION and the sd/bl sizes zeroed. + val manifestJson = + """ + { + "manifest": { + "softdevice_bootloader": { + "bin_file": "sd_bl.bin", + "dat_file": "sd_bl.dat", + "bl_size": 39000, + "sd_size": 151016 + } + } + } + """ + .trimIndent() + + val entries = + mapOf( + "manifest.json" to manifestJson.encodeToByteArray(), + "sd_bl.bin" to byteArrayOf(0x01), + "sd_bl.dat" to byteArrayOf(0x02), + ) + + val ex = assertFailsWith { parseDfuZipEntries(entries) } + assertTrue( + ex.message?.contains("no 'application' image") == true, + "Rejection should name the missing application image, was: ${ex.message}", + ) + assertTrue(ex.message?.contains("sd_bl.bin") == true, "Rejection should name what was found instead") + } + + @Test + fun failsWhenManifestDeclaresOnlyBootloader() { + val manifestJson = + """ + { + "manifest": { + "bootloader": { + "bin_file": "bl.bin", + "dat_file": "bl.dat" + } + } + } + """ + .trimIndent() + + val entries = mapOf("manifest.json" to manifestJson.encodeToByteArray()) + + val ex = assertFailsWith { parseDfuZipEntries(entries) } + assertTrue(ex.message?.contains("no 'application' image") == true) + } + @Test fun failsWhenManifestIsMissing() { val entries = mapOf("app.bin" to byteArrayOf(), "app.dat" to byteArrayOf()) diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 8eb951938c3..6c12ea1fbf8 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt @@ -205,6 +205,17 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl destinationFile.length() } + /** + * Always false on desktop: there is no Storage Access Framework to classify a volume with, and the multi-pass UF2 + * maintenance flow is Android-only. Refusing is the fail-closed answer, consistent with + * [DesktopFirmwareUsbManager] reporting the CDC unblock unsupported. + */ + override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = false + + override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) { + destinationUri.toLocalFileOrNull()?.canRead() == true + } + @Suppress("NestedBlockDepth", "ReturnCount") private fun extractFromZipFile( zipFile: File, From 4283dbdbf3c4a90d1077ce5aef6f07e3699a9fea Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 07:57:05 -0500 Subject: [PATCH 03/19] feat(firmware): resolve OTAFIX bootloaders by the Board-ID the device reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces a guessed name correspondence with the device's own answer, and in doing so widens OTAFIX coverage from 2 boards to all 14. Every Adafruit-family bootloader writes INFO_UF2.TXT to its mass-storage volume with a "Board-ID:" line (ghostfat.c, from each board's UF2_BOARD_ID). Reading that off the mounted drive identifies the hardware authoritatively, so nothing depends on correlating project names — which was never going to work: heltec_t114 vs heltec-mesh-node-t114, thinknode_m1 vs ThinkNode-M1, t1000_e vs tracker-t1000-e. USB identity was the obvious alternative and is unusable: four OTAFIX boards share 239A/0029, all three ThinkNodes share 239A/00DA, and SenseCAP Solar P1 collides with XIAO nRF52840 BLE on 2886/0044. Board-IDs are unique across all 14, which a test asserts. Board-ID is also the only way to resolve the XIAO BLE / BLE Sense split that OTAFIX's own README warns about. The supported-target set is now a visibility hint only — it decides whether the action is offered, never which image is written. Being wrong there costs a tap and an "unsupported board" message after the drive is read; it cannot flash anything. An unrecognized Board-ID refuses, which is correct even on a supported product: it means the installed bootloader is not a pairing we have verified, and a bootloader built for other hardware needs SWD to undo. Co-Authored-By: Claude Opus 5 --- .../feature/firmware/MaintenanceUf2.kt | 163 +++++++++++++++--- .../feature/firmware/UsbMaintenance.kt | 5 +- .../firmware/UsbMaintenanceGateTest.kt | 82 +++++++-- 3 files changed, 212 insertions(+), 38 deletions(-) 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 877e0c3f3e8..023ed59a97f 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 @@ -88,32 +88,123 @@ private val PICO_ERASE = MaintenanceUf2( sha256 = "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853", ) +private fun otafixAsset(board: String, sha256: String): MaintenanceUf2 { + val name = "update-${board}_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2" + return MaintenanceUf2(url = "$OTAFIX_BASE/$name", fileName = name, sha256 = sha256) +} + /** - * OTAFIX bootloader self-update images, keyed by Meshtastic `platformioTarget`. + * OTAFIX bootloader self-update images, keyed by the **Board-ID the device itself reports**. * - * Deliberately sparse. The OTAFIX assets are named after *their* PlatformIO board names, which do not correspond to - * Meshtastic's by any mechanical rule: `heltec_t114` vs `heltec-mesh-node-t114`, `thinknode_m1` vs `ThinkNode-M1`, - * `t1000_e` vs `tracker-t1000-e`. Worse, six distinct Meshtastic products (WISMESH Hub/Tap/Tag, Nomadstar Meteor Pro, - * RAK3401, RAK4631) all build against the single `wiscore_rak4631` board, so a board-level match would offer one - * product's bootloader to five others. + * Every Adafruit-family bootloader writes `INFO_UF2.TXT` to its mass-storage volume containing a `Board-ID:` line + * (`ghostfat.c`, from each board's `UF2_BOARD_ID`). Reading that line off the mounted drive identifies the hardware + * authoritatively, so nothing here depends on correlating names between the two projects — which is impossible anyway: + * `heltec_t114` vs `heltec-mesh-node-t114`, `thinknode_m1` vs `ThinkNode-M1`, `t1000_e` vs `tracker-t1000-e`. * - * A bootloader built for different hardware is as unrecoverable as a wrong-SoftDevice erase, so entries are added only - * where the correspondence is unambiguous *and* the pairing has been validated on real hardware. Everything else - * resolves to null and the upgrade action is simply not offered — see `usbMaintenanceGate`. + * USB VID/PID would not do: four OTAFIX boards share `239A/0029`, all three ThinkNodes share `239A/00DA`, and SenseCAP + * Solar P1 collides with XIAO nRF52840 BLE on `2886/0044`. Board-IDs are unique across all 14, which a test asserts. + * + * This is also the only way to resolve the XIAO nRF52840 BLE / BLE Sense split that OTAFIX's own README calls out: the + * two differ only by Board-ID, and installing the wrong one over UF2 is exactly what that note warns against. */ -private val OTAFIX_BY_TARGET: Map = mapOf( - "rak4631" to - MaintenanceUf2( - url = "$OTAFIX_BASE/update-wiscore_rak4631_board_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", - fileName = "update-wiscore_rak4631_board_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", +private val OTAFIX_BY_BOARD_ID: Map = mapOf( + "HT-n5262" to + otafixAsset( + board = "heltec_t114", + sha256 = "d7a51ef7e41e7ba3b5906c162e8926ca0bc88a5010436a3589991d5a99741220", + ), + "MinewSemi-MX25LE01" to + otafixAsset( + board = "minewsemi_mx25le01", + sha256 = "3d2039b3e22e0b350b49a8456e30709d2f8be440a239d2ed864091b1c4abc3ae", + ), + "TRACKER L1" to + otafixAsset( + board = "wio_tracker_l1", + sha256 = "efe1fc16f64f04deb26d171b9a3f6d81b32767ff01c40fe894924b8c682a2964", + ), + "WisBlock-RAK4631-Board" to + otafixAsset( + board = "wiscore_rak4631_board", sha256 = "3509c8b01296bc6473acf5a9422f9aec857a5bbb47e80000f9b98e15b046e46a", ), - "tracker-t1000-e" to - MaintenanceUf2( - url = "$OTAFIX_BASE/update-t1000_e_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", - fileName = "update-t1000_e_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2", + "WisMesh-Tag" to + otafixAsset( + board = "wismesh_tag", + sha256 = "e1701badf22d9684d953cb5274d5d9241c3bf776c2524b935ef21a607c0bca4a", + ), + "nRF52840-SeeedSenseCAPSolarP1-v1" to + otafixAsset( + board = "sensecap_solar_p1", + sha256 = "efe28af706a4a3e5604390d3bf5bcbeb7470aadc957e191c7e26e8fadc2ed4e5", + ), + "nRF52840-SeeedXiao-v1" to + otafixAsset( + board = "xiao_nrf52840_ble", + sha256 = "49364762fb9992334fe55b3bea7f2d30e14dce4433de6c31e90036f6ec95d2a4", + ), + "nRF52840-SeeedXiaoSense-v1" to + otafixAsset( + board = "xiao_nrf52840_ble_sense", + sha256 = "fc492fc2e30f2f75789217c0ead68ad64917de0844e6011269755fddfe532c36", + ), + "nRF52840-T1000-E-v1" to + otafixAsset( + board = "t1000_e", sha256 = "c1dd30cce0f250eb7ad21e8c065cef6692b53e7efcba0578371bedcfd2493cc9", ), + "nRF52840-TEcho-v1" to + otafixAsset( + board = "lilygo_techo", + sha256 = "244c3ea9a783dbcfd9fabfa95d5f8bae3a52246bbe41c5414b2b6657683025c9", + ), + "nRF52840-ThinkNode-M3-v1" to + otafixAsset( + board = "thinknode_m3", + sha256 = "073a6b6acf1bb0ca9ea4e8f7ca3c8df1cf3992aced06650b85c00aba5fa3ff13", + ), + "nRF52840-ThinkNodeM1-v1" to + otafixAsset( + board = "thinknode_m1", + sha256 = "315d36a189b30bbc09c7846031483caea669b1d0c7237da9221ab2794757498f", + ), + "nRF52840-ThinkNodeM6-v1" to + otafixAsset( + board = "thinknode_m6", + sha256 = "a7e977a8af02946559a8c703f1d2f27e22fd1cbedf61a9bc7af3472eb86fe99f", + ), + "nRF52840-promicro" to + otafixAsset( + board = "promicro_nrf52840", + sha256 = "5ceb9ad4a8092f1319fc5371649eeae55b47c5ff2acd647972558017ba51dccb", + ), +) + +/** + * Meshtastic `platformioTarget`s whose products OTAFIX lists as supported. + * + * A **UX gate only** — it decides whether the upgrade action is offered, never which image is written. Being wrong here + * costs a user a tap and an "unsupported board" message after the drive is read; it cannot flash anything. Every + * correctness decision is made by [otafixUf2ForBoardId] against the Board-ID on the drive. + * + * Derived from OTAFIX's "Boards supported" list. Deliberately excludes products that merely share a build target with a + * supported one — WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against `wiscore_rak4631`, and T-Echo + * Plus/Lite against `t-echo`, but OTAFIX ships no bootloader for them. + */ +private val OTAFIX_SUPPORTED_TARGETS: Set = setOf( + "rak4631", + "rak_wismeshtag", + "t-echo", + "heltec-mesh-node-t114", + "nrf52_promicro_diy_tcxo", + "thinknode_m1", + "thinknode_m3", + "thinknode_m6", + "tracker-t1000-e", + "seeed_wio_tracker_L1", + "seeed_wio_tracker_L1_eink", + "seeed_solar_node", + "seeed_xiao_nrf52840_kit", ) /** @@ -136,8 +227,40 @@ internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when { else -> null } -/** The OTAFIX bootloader self-update image for [platformioTarget], or `null` when this board is not mapped. */ -internal fun otafixUf2For(platformioTarget: String): MaintenanceUf2? = OTAFIX_BY_TARGET[platformioTarget] +/** True when OTAFIX lists a bootloader for [platformioTarget]'s product. UX gate only — see [OTAFIX_SUPPORTED_TARGETS]. */ +internal fun otafixSupportsTarget(platformioTarget: String): Boolean = platformioTarget in OTAFIX_SUPPORTED_TARGETS + +/** + * The OTAFIX image matching the [boardId] a device reported in its `INFO_UF2.TXT`, or `null` when unrecognized. + * + * `null` refuses the upgrade. That is the correct outcome even for a board OTAFIX supports: an unrecognized Board-ID + * means the installed bootloader is not one we have a verified pairing for, and writing a bootloader built for other + * hardware is unrecoverable without SWD. + */ +internal fun otafixUf2ForBoardId(boardId: String): MaintenanceUf2? = OTAFIX_BY_BOARD_ID[boardId.trim()] + +/** Board-IDs we ship an OTAFIX image for. Exposed for the uniqueness test. */ +internal val otafixBoardIds: Set + get() = OTAFIX_BY_BOARD_ID.keys + +/** + * Extracts the `Board-ID:` value from the contents of a UF2 bootloader's `INFO_UF2.TXT`. + * + * Format is fixed by `ghostfat.c`: `UF2 Bootloader ` / `Model: ` / `Board-ID: ` / `Date: `, + * CRLF-separated. Returns `null` when the line is absent, which means the volume is not an Adafruit-family UF2 + * bootloader drive — itself a reason to refuse a destructive write. + */ +internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text + .lineSequence() + .firstOrNull { it.startsWith(UF2_BOARD_ID_PREFIX, ignoreCase = true) } + ?.substringAfter(':') + ?.trim() + ?.takeIf { it.isNotEmpty() } + +/** 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_BOARD_ID_PREFIX = "Board-ID:" /** UF2 block size, per the UF2 specification. */ internal const val UF2_BLOCK_BYTES = 512 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 273785b489e..30d30d7118b 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 @@ -109,7 +109,8 @@ internal fun usbMaintenanceGate( return UsbMaintenanceGate( show = true, eraseRefusal = eraseRefusal, - // nRF-only: RP2040 boards run no Adafruit bootloader, so OTAFIX does not apply. - showBootloaderUpgrade = hardware.isNrf52Arc && otafixUf2For(hardware.effectiveTarget) != null, + // 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(hardware.effectiveTarget), ) } 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 ae8bda2ad9d..7f8efa0c2db 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 @@ -139,38 +139,88 @@ class UsbMaintenanceGateTest { // ── OTAFIX bootloader image resolution (R5) ────────────────────────────── @Test - fun `otafix is offered only for mapped boards`() { + fun `otafix is offered only for supported targets`() { assertTrue( usbMaintenanceGate(nrf(target = "rak4631"), FirmwareUpdateMethod.Usb, hasRelease = true) .showBootloaderUpgrade, ) - assertFalse( + assertTrue( usbMaintenanceGate(nrf(target = "heltec-mesh-node-t114"), FirmwareUpdateMethod.Usb, hasRelease = true) .showBootloaderUpgrade, - "An unmapped board hides the action rather than guessing a bootloader built for other hardware", + "T114 is on OTAFIX's supported list even though the project names differ", ) } @Test - fun `otafix images are board specific`() { - val rak = otafixUf2For("rak4631") - val t1000 = otafixUf2For("tracker-t1000-e") + fun `otafix is not offered for products that merely share a supported build target`() { + // WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against wiscore_rak4631, and T-Echo Plus/Lite + // against t-echo, but OTAFIX ships no bootloader for those products. + listOf("rak2560", "rak_wismeshtap", "rak4631_nomadstar_meteor_pro", "rak3401-1watt", "t-echo-plus", "t-echo-lite") + .forEach { target -> + assertFalse( + usbMaintenanceGate(nrf(target = target), FirmwareUpdateMethod.Usb, hasRelease = true) + .showBootloaderUpgrade, + "$target is not an OTAFIX-supported product", + ) + } + } + + // ── Board-ID resolution: the actual safety gate (R5) ───────────────────── + + @Test + fun `every shipped otafix image has a unique board id and matching filename`() { + assertEquals(14, otafixBoardIds.size, "OTAFIX 2.2-BP1.3 ships 14 update images") + val digests = otafixBoardIds.mapNotNull { otafixUf2ForBoardId(it)?.sha256 } + assertEquals(digests.size, digests.toSet().size, "No two boards may share a bootloader digest") + } + + @Test + fun `board id selects the image, not the build target`() { + val rak = otafixUf2ForBoardId("WisBlock-RAK4631-Board") + val techo = otafixUf2ForBoardId("nRF52840-TEcho-v1") assertNotNull(rak) - assertNotNull(t1000) + assertNotNull(techo) assertTrue(rak.fileName.contains("wiscore_rak4631_board")) - assertTrue(t1000.fileName.contains("t1000_e")) - assertTrue(rak.sha256 != t1000.sha256, "Distinct boards must not share a bootloader digest") + assertTrue(techo.fileName.contains("lilygo_techo")) + // Both boards report USB 239A/0029 in bootloader mode, so USB identity could not have told them apart. + assertTrue(rak.sha256 != techo.sha256) } @Test - fun `otafix rejects boards that merely share the rak4631 build target`() { - // WISMESH Hub/Tap/Tag, Nomadstar Meteor Pro and RAK3401 all build against the wiscore_rak4631 board, but they - // are different products. Matching at board level would offer one product's bootloader to the others. - listOf("rak2560", "rak_wismeshtap", "rak_wismeshtag", "rak4631_nomadstar_meteor_pro", "rak3401-1watt") - .forEach { target -> - assertNull(otafixUf2For(target), "$target must not resolve the RAK4631 bootloader") - } + fun `xiao sense is distinguished from plain xiao only by board id`() { + val plain = otafixUf2ForBoardId("nRF52840-SeeedXiao-v1") + val sense = otafixUf2ForBoardId("nRF52840-SeeedXiaoSense-v1") + + assertNotNull(plain) + assertNotNull(sense) + assertTrue(plain.fileName.contains("xiao_nrf52840_ble_bootloader")) + assertTrue(sense.fileName.contains("xiao_nrf52840_ble_sense")) + assertTrue(plain.sha256 != sense.sha256, "OTAFIX's README warns these must not be interchanged") + } + + @Test + fun `an unrecognized board id refuses rather than falling back`() { + assertNull(otafixUf2ForBoardId("SomeOtherBoard-v9")) + assertNull(otafixUf2ForBoardId("")) + } + + @Test + fun `board id is parsed from an INFO_UF2 payload and tolerates surrounding lines`() { + val info = + "UF2 Bootloader 0.9.2-OTAFIX2.2-BP1.3\r\n" + + "Model: WisBlock RAK4631 Board\r\n" + + "Board-ID: WisBlock-RAK4631-Board\r\n" + + "Date: Apr 13 2026\r\n" + + assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(info)) + assertNotNull(parseUf2BoardId(info)?.let { otafixUf2ForBoardId(it) }) + } + + @Test + fun `a payload without a board id line yields null`() { + assertNull(parseUf2BoardId("UF2 Bootloader 0.2.6\r\nModel: Something\r\n")) + assertNull(parseUf2BoardId(""), "A volume with no INFO_UF2.TXT is not a UF2 bootloader drive") } // ── UF2 header parsing (R8) ────────────────────────────────────────────── From 15e3cce7fd9301b760c228a92e575034c997df77 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:09:27 -0500 Subject: [PATCH 04/19] feat(firmware): read the installed SoftDevice off the drive instead of trusting the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a stock Seeed Wio Tracker L1 (hwModel 99, firmware 2.8.0): Board-ID: TRACKER L1 SoftDevice: S140 7.3.0 uf2_init() appends that SoftDevice line at boot from SD_ID_GET(MBR_SIZE) and SD_VERSION_GET(MBR_SIZE) — read out of the MBR's own registers. It is present in upstream Adafruit, in OTAFIX, and now confirmed on a stock Seeed bootloader. So the device can tell us not what its firmware was built against, but which SoftDevice is actually in flash — which is the question that decides whether an erase image lands in the application region or in the SoftDevice. The bundled 31-row map is therefore demoted to a pre-flight hint: it decides whether to offer the action before any drive is mounted, and cannot by itself cause a write. Once the drive is readable its report wins. A disagreement between the two refuses rather than picking a side, which also makes the map self-checking — a wrong row surfaces the first time anyone uses it instead of corrupting a SoftDevice silently. A device with no SoftDevice line (bootloader older than that uf2_init) still falls back to the map, and a drive report can now rescue a model the map has no row for at all, such as THINKNODE_M8. Also verified while the L1 was mounted, both of which de-risk earlier assumptions rather than changing code: - CURRENT.UF2's first block targets 0x00001000, empirically confirming USER_FLASH_START == MBR_SIZE — the reason a wrong-variant erase can reach the SoftDevice at all. - ghostfat.c falls CFG_UF2_BOARD_APP_ID (VID<<16|PID, why CURRENT.UF2 is tagged 0x28861667 and thus board-locked) and CFG_UF2_FAMILY_APP_ID (0xADA52840) through to the same write path, so the Adafruit-family erase images are accepted by Seeed's bootloader. Co-Authored-By: Claude Opus 5 --- .../feature/firmware/MaintenanceUf2.kt | 73 ++++++++++++++++++ .../firmware/UsbMaintenanceGateTest.kt | 75 +++++++++++++++++++ 2 files changed, 148 insertions(+) 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 023ed59a97f..4fd7c77856c 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 @@ -257,11 +257,84 @@ internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text ?.trim() ?.takeIf { it.isNotEmpty() } +/** + * Extracts the installed SoftDevice from the contents of a UF2 bootloader's `INFO_UF2.TXT`. + * + * `uf2_init()` appends this line at boot from `SD_ID_GET(MBR_SIZE)`/`SD_VERSION_GET(MBR_SIZE)` — i.e. read out of the + * MBR's registers — formatted as `SoftDevice: S ..`. Present in upstream Adafruit and in + * OTAFIX, and verified on a stock Seeed bootloader (`SoftDevice: S140 7.3.0`). + * + * This is the **authoritative** answer to the question the bundled map only estimates: not what the firmware was built + * against, but which SoftDevice is actually in flash. Returns `null` when the line is absent (very old bootloader), when + * no SoftDevice is installed, or when the id/version is not one we ship an erase image for. + */ +internal fun parseUf2SoftDevice(infoUf2Text: String): SoftDeviceVariant? { + val value = + infoUf2Text + .lineSequence() + .firstOrNull { it.trimStart().startsWith(UF2_SOFTDEVICE_PREFIX, ignoreCase = true) } + ?.substringAfter(':') + ?.trim() + ?: return null + + val parts = value.split(' ').filter { it.isNotBlank() } + if (parts.size < 2 || !parts[0].equals(SUPPORTED_SOFTDEVICE_ID, ignoreCase = true)) return null + return SoftDeviceVariant.fromWire(parts[1]) +} + +/** Which erase image a given variant needs. Total over the enum, so a new variant cannot silently reuse an old image. */ +internal fun eraseUf2ForVariant(variant: SoftDeviceVariant): MaintenanceUf2 = when (variant) { + SoftDeviceVariant.S140_6_1_1 -> ERASE_S140_6_1_1 + SoftDeviceVariant.S140_7_3_0 -> ERASE_S140_7_3_0 +} + +/** Outcome of reconciling the SoftDevice the drive reports against the bundled map. */ +internal sealed interface EraseImageResolution { + /** Safe to write [asset]; [variant] is the SoftDevice it is linked for. */ + data class Resolved(val asset: MaintenanceUf2, val variant: SoftDeviceVariant) : EraseImageResolution + + /** + * The drive and the bundled map disagree. Always a refusal: one of the two is wrong and we cannot tell which, and + * guessing writes an erase image into a SoftDevice. Also the signal that a map row needs correcting. + */ + data class Conflict(val reported: SoftDeviceVariant, val mapped: SoftDeviceVariant) : EraseImageResolution + + /** Neither source produced a variant. */ + data object Unresolved : EraseImageResolution +} + +/** + * Picks the nRF erase image, preferring what the device reports over what the bundled map predicted. + * + * The map ([DeviceHardware.softDeviceVariant]) is a pre-flight hint — it decides whether the action is offered before any + * drive is mounted. Once the drive is readable its own report wins, because it comes from the MBR rather than from a + * hand-authored table. A disagreement refuses rather than picking a side. + */ +internal fun resolveNrfEraseImage( + mapped: SoftDeviceVariant?, + reportedFromDrive: SoftDeviceVariant?, +): EraseImageResolution = when { + reportedFromDrive != null && mapped != null && reportedFromDrive != mapped -> + EraseImageResolution.Conflict(reported = reportedFromDrive, mapped = mapped) + + reportedFromDrive != null -> EraseImageResolution.Resolved(eraseUf2ForVariant(reportedFromDrive), reportedFromDrive) + + // No SoftDevice line: a bootloader older than the uf2_init that emits it. Fall back to the bundled hint. + mapped != null -> EraseImageResolution.Resolved(eraseUf2ForVariant(mapped), mapped) + + else -> EraseImageResolution.Unresolved +} + /** 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_BOARD_ID_PREFIX = "Board-ID:" +private const val UF2_SOFTDEVICE_PREFIX = "SoftDevice:" + +/** All Meshtastic nRF52840 boards run the S140 SoftDevice; anything else is out of scope and refuses. */ +private const val SUPPORTED_SOFTDEVICE_ID = "S140" + /** UF2 block size, per the UF2 specification. */ internal const val UF2_BLOCK_BYTES = 512 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 7f8efa0c2db..4dd5851eba1 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 @@ -223,6 +223,81 @@ class UsbMaintenanceGateTest { assertNull(parseUf2BoardId(""), "A volume with no INFO_UF2.TXT is not a UF2 bootloader drive") } + // ── SoftDevice read from the drive: the authoritative gate (R4) ─────────── + + /** Verbatim `INFO_UF2.TXT` from a stock Seeed Wio Tracker L1 (hwModel 99), captured 2026-07-30. */ + private val seeedL1Info = + "UF2 Bootloader 0.9.2-dirty lib/nrfx (v2.0.0) lib/tinyusb (0.12.0-145-g9775e7691) " + + "lib/uf2 (remotes/origin/configupdate-9-gadbb8c7)\r\n" + + "Model: Seeed TRACKER L1\r\n" + + "Board-ID: TRACKER L1\r\n" + + "Date: May 15 2025\r\n" + + "SoftDevice: S140 7.3.0\r\n" + + @Test + fun `softdevice and board id are both read from a real stock bootloader payload`() { + assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice(seeedL1Info)) + assertEquals("TRACKER L1", parseUf2BoardId(seeedL1Info)) + // The stock Seeed bootloader reports the same Board-ID as OTAFIX's build for this board. + assertNotNull(otafixUf2ForBoardId(parseUf2BoardId(seeedL1Info)!!)) + } + + @Test + fun `softdevice line is parsed for both shipped variants`() { + assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice("SoftDevice: S140 6.1.1\r\n")) + assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice("SoftDevice: S140 7.3.0\r\n")) + } + + @Test + fun `unsupported softdevice ids and absent lines yield null`() { + assertNull(parseUf2SoftDevice("SoftDevice: S132 7.3.0\r\n"), "S132 is not an nRF52840 SoftDevice") + assertNull(parseUf2SoftDevice("SoftDevice: S140 9.9.9\r\n"), "No erase image exists for an unknown version") + assertNull(parseUf2SoftDevice("SoftDevice: \r\n"), "The bootloader omits the value when no SD is installed") + assertNull(parseUf2SoftDevice("Board-ID: TRACKER L1\r\n"), "Absent line") + } + + @Test + fun `the drive outranks the bundled map`() { + // The map says 6.1.1, the device says 7.3.0 with nothing else to go on — but they disagree, so refuse. + val conflict = resolveNrfEraseImage(mapped = SoftDeviceVariant.S140_6_1_1, reportedFromDrive = SoftDeviceVariant.S140_7_3_0) + assertTrue(conflict is EraseImageResolution.Conflict, "Disagreement must refuse, not pick a side") + } + + @Test + fun `agreement resolves to the reported variant's image`() { + val resolved = + resolveNrfEraseImage( + mapped = SoftDeviceVariant.S140_7_3_0, + reportedFromDrive = SoftDeviceVariant.S140_7_3_0, + ) + + assertTrue(resolved is EraseImageResolution.Resolved) + assertEquals("nrf_erase_sd7_3.uf2", resolved.asset.fileName) + assertEquals(APP_START_S140_7_3_0, resolved.asset.expectedFirstTargetAddress) + } + + @Test + fun `an old bootloader with no softdevice line falls back to the bundled map`() { + val resolved = resolveNrfEraseImage(mapped = SoftDeviceVariant.S140_6_1_1, reportedFromDrive = null) + + assertTrue(resolved is EraseImageResolution.Resolved) + assertEquals("nrf_erase2.uf2", resolved.asset.fileName) + } + + @Test + fun `a drive report rescues an unmapped model`() { + // THINKNODE_M8 has no firmware variant on master and so no map row; the drive can still answer. + val resolved = resolveNrfEraseImage(mapped = null, reportedFromDrive = SoftDeviceVariant.S140_6_1_1) + + assertTrue(resolved is EraseImageResolution.Resolved) + assertEquals(SoftDeviceVariant.S140_6_1_1, resolved.variant) + } + + @Test + fun `neither source resolving means unresolved`() { + assertEquals(EraseImageResolution.Unresolved, resolveNrfEraseImage(mapped = null, reportedFromDrive = null)) + } + // ── UF2 header parsing (R8) ────────────────────────────────────────────── @Test From 57d405431b3a4ad96e2d03a0c830d0bfdcd3ce95 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:27:55 -0500 Subject: [PATCH 05/19] test(firmware): anchor both SoftDevice variants to captured hardware payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware verification on two real devices, 2026-07-30. RAK4631 (hwModel 9), already running OTAFIX 2.2-BP1.3: Board-ID: WisBlock-RAK4631-Board SoftDevice: S140 6.1.1 Seeed Wio Tracker L1 (hwModel 99), stock Seeed bootloader 0.9.2: Board-ID: TRACKER L1 SoftDevice: S140 7.3.0 Both Board-IDs match the shipped map keys exactly, and both SoftDevice readings match their map rows — so the two sides of the variant split are now anchored to captured payloads rather than to my derivation from board ldscripts. Both payloads are test fixtures. The app-start constants are verified from the flash dump rather than assumed: on the 6.1.1 RAK, CURRENT.UF2 at 0x26000 holds a valid ARM vector table (sp=0x20040000, exactly the top of nRF52840 RAM; reset odd, Thumb bit set), while 0x27000 holds mid-application bytes. On the 7.3.0 device the app starts at 0x27000 instead, which is what makes 0x26000 the SoftDevice's last page and that mismatch direction destructive. The benign direction is confirmed benign for the same reason: a 7.3.0 image on a 6.1.1 device lands inside the app. Also confirmed empirically: OTAFIX's uf2_init does emit the SoftDevice line, so an upgraded device keeps reporting its variant; and an OTAFIX-flashed device still resolves its own image, making a repeat upgrade idempotent rather than an error. Co-Authored-By: Claude Opus 5 --- .../firmware/UsbMaintenanceGateTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 4dd5851eba1..bf72296ff4f 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 @@ -242,12 +242,45 @@ class UsbMaintenanceGateTest { assertNotNull(otafixUf2ForBoardId(parseUf2BoardId(seeedL1Info)!!)) } + /** Verbatim `INFO_UF2.TXT` from a RAK4631 running OTAFIX 2.2-BP1.3 (hwModel 9), captured 2026-07-30. */ + private val rak4631OtafixInfo = + "UF2 Bootloader 0.9.2-OTAFIX2.2-BP1.3 lib/nrfx (v2.0.0) lib/tinyusb (0.12.0-145-g9775e7691) " + + "lib/uf2 (remotes/origin/configupdate-9-gadbb8c7)\r\n" + + "Model: WisBlock RAK4631 Board\r\n" + + "Board-ID: WisBlock-RAK4631-Board\r\n" + + "Date: Apr 13 2026\r\n" + + "SoftDevice: S140 6.1.1\r\n" + + @Test + fun `both variants are parsed from real captured bootloader payloads`() { + // The two sides of the split, each from hardware: an OTAFIX RAK4631 and a stock Seeed L1. + assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice(rak4631OtafixInfo)) + assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice(seeedL1Info)) + + assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(rak4631OtafixInfo)) + assertNotNull( + otafixUf2ForBoardId(parseUf2BoardId(rak4631OtafixInfo)!!), + "An OTAFIX-flashed device must still resolve its own image, so re-running the upgrade is idempotent", + ) + } + @Test fun `softdevice line is parsed for both shipped variants`() { assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice("SoftDevice: S140 6.1.1\r\n")) assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice("SoftDevice: S140 7.3.0\r\n")) } + @Test + fun `each erase image targets the app start of the softdevice it is linked for`() { + // Verified against hardware 2026-07-30: on a 6.1.1 RAK4631 the app vector table sits at 0x26000 + // (sp=0x20040000, top of nRF52840 RAM), and 0x27000 is mid-application. On a 7.3.0 device the app starts at + // 0x27000, which makes 0x26000 the SoftDevice's last page — the direction that corrupts. + assertEquals(0x26000L, APP_START_S140_6_1_1) + assertEquals(0x27000L, APP_START_S140_7_3_0) + assertEquals(APP_START_S140_6_1_1, eraseUf2ForVariant(SoftDeviceVariant.S140_6_1_1).expectedFirstTargetAddress) + assertEquals(APP_START_S140_7_3_0, eraseUf2ForVariant(SoftDeviceVariant.S140_7_3_0).expectedFirstTargetAddress) + } + @Test fun `unsupported softdevice ids and absent lines yield null`() { assertNull(parseUf2SoftDevice("SoftDevice: S132 7.3.0\r\n"), "S132 is not an nRF52840 SoftDevice") From ccafa03429b072ab3136a0624dfbbf37f9342427 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:34:10 -0500 Subject: [PATCH 06/19] test(firmware): cover the 0.4.3 bootloader vintage and Board-ID stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third device, a stock RAK4631 on a 0.4.3 bootloader (May 2023), closes the last open verification from the drive-authority design: UF2 Bootloader 0.4.3 Board-ID: WisBlock-RAK4631-Board Ver: 0.4.3 SoftDevice: S140 6.1.1 - The SoftDevice line goes back at least to 0.4.3, so the bundled-map fallback is belt-and-braces rather than the common path for older hardware. - This vintage emits an extra "Ver:" line that 0.9.x dropped. The parser scans lines by prefix so it already tolerates it; a fixture now pins that. - Board-ID is identical on stock 0.4.3 and on OTAFIX 2.2, so the OTAFIX veto resolves correctly on a device that has never been upgraded — which is the case that decides whether the upgrade can be offered at all. That was the assumption flagged as unverified when the veto was introduced. - 0x26000 is confirmed as the 6.1.1 app start on a second, different-vintage device: same sp=0x20040000 signature at that address. Co-Authored-By: Claude Opus 5 --- .../firmware/UsbMaintenanceGateTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 bf72296ff4f..c5889483fa7 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 @@ -251,6 +251,34 @@ class UsbMaintenanceGateTest { "Date: Apr 13 2026\r\n" + "SoftDevice: S140 6.1.1\r\n" + /** + * Verbatim `INFO_UF2.TXT` from a RAK4631 on a stock 0.4.3 bootloader (May 2023), captured 2026-07-30. + * + * Two things this vintage proves: the `SoftDevice:` line goes back at least this far, so the bundled-map fallback is + * belt-and-braces rather than the common path; and older bootloaders emit an extra `Ver:` line that 0.9.x dropped, + * which the line-scanning parser must tolerate. + */ + private val rak4631StockInfo = + "UF2 Bootloader 0.4.3\r\n" + + "Model: WisBlock RAK4631 Board\r\n" + + "Board-ID: WisBlock-RAK4631-Board\r\n" + + "Date: May 20 2023\r\n" + + "Ver: 0.4.3\r\n" + + "SoftDevice: S140 6.1.1\r\n" + + @Test + fun `an old bootloader vintage still reports its softdevice and board id`() { + assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice(rak4631StockInfo)) + assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(rak4631StockInfo)) + } + + @Test + fun `board id is stable across bootloader vintages for the same board`() { + // A stock 0.4.3 RAK and an OTAFIX 2.2 RAK report the same Board-ID, so the OTAFIX veto resolves correctly on a + // device that has never been upgraded — the case that decides whether the upgrade is offerable at all. + assertEquals(parseUf2BoardId(rak4631StockInfo), parseUf2BoardId(rak4631OtafixInfo)) + } + @Test fun `both variants are parsed from real captured bootloader payloads`() { // The two sides of the split, each from hardware: an OTAFIX RAK4631 and a stock Seeed L1. From 77bb018e7dbca0a35d0bf42a304c9fe19c754e4c Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:38:40 -0500 Subject: [PATCH 07/19] feat(firmware): add UF2 volume tree read/write primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading INFO_UF2.TXT off a mounted UF2 volume needs sibling access, which a single-document URI from ACTION_CREATE_DOCUMENT does not grant — hence tree operations. readSiblingText fetches the file that both identifies the board and reports the installed SoftDevice, and whose mere presence is positive proof the picked volume is an Adafruit-family bootloader drive rather than Downloads. createDocumentInTree is the counterpart: once the volume is vetted, the app names the file instead of asking the user to. isRemovableDestination now accepts a tree URI as well as a document URI, since the maintenance flow vets the volume rather than a filename. The desktop actuals are implemented against directories rather than stubbed — unreachable today because isRemovableDestination refuses first, but obvious to whoever builds a desktop flow later. Co-Authored-By: Claude Opus 5 --- .../firmware/AndroidFirmwareFileHandler.kt | 53 ++++++++++++++++++- .../feature/firmware/FirmwareFileHandler.kt | 19 +++++++ .../firmware/CommonFirmwareRetrieverTest.kt | 5 ++ .../firmware/JvmFirmwareFileHandler.kt | 24 +++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 54838008dcd..d035565f03c 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -337,7 +337,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien runCatching { val androidUri = destinationUri.toAndroidUri() if (androidUri.authority != EXTERNAL_STORAGE_AUTHORITY) return@runCatching false - val documentId = DocumentsContract.getDocumentId(androidUri) + // Accepts either a tree URI (the maintenance flow picks the volume) or a single document URI. + val documentId = + runCatching { DocumentsContract.getTreeDocumentId(androidUri) }.getOrNull() + ?: DocumentsContract.getDocumentId(androidUri) val volumeId = documentId.substringBefore(':', missingDelimiterValue = "") volumeId.isNotBlank() && !volumeId.equals(PRIMARY_VOLUME_ID, ignoreCase = true) } @@ -350,6 +353,54 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien .getOrDefault(false) } + override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = withContext(ioDispatcher) { + runCatching { + val tree = treeUri.toAndroidUri() + val treeDocumentId = DocumentsContract.getTreeDocumentId(tree) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, treeDocumentId) + val documentId = + context.contentResolver + .query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + ), + null, + null, + null, + ) + ?.use { cursor -> + var found: String? = null + while (cursor.moveToNext()) { + if (cursor.getString(1).equals(fileName, ignoreCase = true)) { + found = cursor.getString(0) + break + } + } + found + } ?: return@runCatching null + + val documentUri = DocumentsContract.buildDocumentUriUsingTree(tree, documentId) + context.contentResolver.openInputStream(documentUri)?.use { it.readBytes().decodeToString() } + } + .onFailure { Logger.w(it) { "Could not read $fileName from $treeUri" } } + .getOrNull() + } + + override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? = + withContext(ioDispatcher) { + runCatching { + val tree = treeUri.toAndroidUri() + val parent = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + DocumentsContract.createDocument(context.contentResolver, parent, mimeType, fileName)?.let { + CommonUri.parse(it.toString()) + } + } + .onFailure { Logger.w(it) { "Could not create $fileName in $treeUri" } } + .getOrNull() + } + override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long = withContext(ioDispatcher) { val inputStream = diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index a9272982bf2..3736384286b 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -81,6 +81,25 @@ interface FirmwareFileHandler { */ suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean + /** + * Reads a file sitting alongside the documents in [treeUri], or `null` when it is absent or unreadable. + * + * Used to read `INFO_UF2.TXT` off a mounted UF2 bootloader volume before writing to it. That file both identifies + * the board (`Board-ID:`) and reports the installed SoftDevice, and its mere presence is positive proof the picked + * volume really is an Adafruit-family UF2 bootloader drive rather than, say, Downloads. + * + * Requires a tree URI (`ACTION_OPEN_DOCUMENT_TREE`); a single-document URI grants no sibling access. + */ + suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? + + /** + * Creates [fileName] inside [treeUri] and returns a URI to write to, or `null` on failure. + * + * The tree-picker counterpart to the save-file launcher: once the volume itself has been vetted, the app names the + * file rather than asking the user to. + */ + suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? + /** * True when [destinationUri] can still be opened for reading. * diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt index 59d05d357eb..d93683ecd5e 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt @@ -643,5 +643,10 @@ abstract class CommonFirmwareRetrieverTest { override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = true override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = false + + override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = null + + override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? = + null } } diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 6c12ea1fbf8..4fc39672ac6 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt @@ -216,6 +216,30 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl destinationUri.toLocalFileOrNull()?.canRead() == true } + /** + * Directory-based equivalents of the Android tree operations. Reachable only if a desktop maintenance flow is ever + * built — [isRemovableDestination] refuses first today — but implemented rather than stubbed so the behaviour is + * obvious to whoever gets there. + */ + override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = withContext(ioDispatcher) { + val dir = treeUri.toLocalFileOrNull() ?: return@withContext null + dir.listFiles() + ?.firstOrNull { it.name.equals(fileName, ignoreCase = true) } + ?.let { file -> runCatching { file.readText() }.getOrNull() } + } + + override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? = + withContext(ioDispatcher) { + val dir = treeUri.toLocalFileOrNull() ?: return@withContext null + runCatching { + dir.mkdirs() + val target = File(dir, fileName) + target.createNewFile() + CommonUri.parse(target.toURI().toString()) + } + .getOrNull() + } + @Suppress("NestedBlockDepth", "ReturnCount") private fun extractFromZipFile( zipFile: File, From ae3ff4e972d69cfc71c28f6723884aec81296750 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:41:22 -0500 Subject: [PATCH 08/19] feat(ui): add a directory-picker launcher alongside the save-file launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tree URI grants access to the picked directory's contents; a single-document URI from ACTION_CREATE_DOCUMENT does not. The firmware maintenance flow has to inspect a volume before writing to it — reading a UF2 bootloader's INFO_UF2.TXT to confirm which board it is and that the volume is a bootloader drive at all. Deliberately a sibling of rememberSaveFileLauncher rather than a change to it: the plain firmware update path still wants the file-naming dialog, and its behaviour is covered by shipped tests. Desktop uses JFileChooser since AWT FileDialog cannot portably select directories. iOS is a no-op stub like its neighbours. Co-Authored-By: Claude Opus 5 --- .../org/meshtastic/core/ui/util/PlatformUtils.kt | 9 +++++++++ .../org/meshtastic/core/ui/util/PlatformUtils.kt | 10 ++++++++++ .../kotlin/org/meshtastic/core/ui/util/NoopStubs.kt | 2 ++ .../org/meshtastic/core/ui/util/PlatformUtils.kt | 13 +++++++++++++ 4 files changed, 34 insertions(+) diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 062b54f0ac9..9218ecf6378 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -146,6 +146,15 @@ actual fun rememberSaveFileLauncher( } } +@Composable +actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + onTreeUriReceived(uri?.toKmpUri()) + } + return remember(launcher) { { launcher.launch(null) } } +} + @Composable actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit { val launcher = diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 560521c3c55..3ec7f9d4c67 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -46,6 +46,16 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */ @Composable expect fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit +/** + * Returns a launcher that prompts the user to pick a *directory*. The callback receives a tree URI, or `null` if the + * user cancelled. + * + * Distinct from [rememberSaveFileLauncher] because a tree URI grants access to the picked directory's contents, which a + * single-document URI does not. Needed where the app must inspect a volume before writing to it — for example reading a + * UF2 bootloader's `INFO_UF2.TXT` to confirm which board it is and that the volume really is a bootloader drive. + */ +@Composable expect fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit + /** * Returns a suspend function that reads up to [maxChars] characters of text from a [CommonUri]. Returns `null` if the * file is empty or cannot be read. diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 2f3fe81e844..fc3dc3762b2 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -46,6 +46,8 @@ actual fun rememberSaveFileLauncher( @Composable actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> } +@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit = {} + @Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { _, _ -> null } @Composable actual fun KeepScreenOn(enabled: Boolean) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 846a8e27631..8a295a53949 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -29,6 +29,7 @@ import java.awt.FileDialog import java.awt.Frame import java.io.File import java.net.URI +import javax.swing.JFileChooser /** JVM stub — NFC settings are not available on Desktop. */ @Composable @@ -88,6 +89,18 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT } } +/** JVM — Opens a native dialog to pick a directory. */ +@Composable +actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit = { + // AWT FileDialog cannot select directories portably; JFileChooser can. + val chooser = JFileChooser().apply { fileSelectionMode = JFileChooser.DIRECTORIES_ONLY } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + onTreeUriReceived(CommonUri.parse(chooser.selectedFile.toURI().toString())) + } else { + onTreeUriReceived(null) + } +} + /** JVM — Reads text from a file URI. */ @Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> From 9366041eb3647edf02fcd767342070db510d5e4e Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 08:45:28 -0500 Subject: [PATCH 09/19] feat(firmware): vet the UF2 volume and choose the image from what it reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two decisions that gate every destructive write, as pure functions with hardware-shaped fixtures. inspectMaintenanceVolume runs two checks, cheapest first: the destination must be removable, and it must expose a readable INFO_UF2.TXT carrying a Board-ID. The second is the load-bearing one — positive proof of an Adafruit-family bootloader drive, where removability only makes "somewhere on internal storage" less likely. This is what stops the common mis-tap, saving into Downloads, from being indistinguishable from a successful flash. It also covers the CDC-only bootloader mode, where no mass-storage volume exists at all, and it works unchanged for RP2040 BOOTSEL volumes, which publish an INFO_UF2.TXT with no SoftDevice line. chooseMaintenanceImage prefers what the mounted volume reports over what the bundled map predicted, and is total over both requests with no default image on any path: agreement resolves, disagreement refuses as SoftDeviceConflict, a volume that reports nothing falls back to the map, and neither knowing refuses. Bootloader upgrades resolve purely from the reported Board-ID, so a device that identifies as a T-Echo gets the T-Echo bootloader even if the catalog target said otherwise. Both run before anything is written, so every refusal costs a message rather than a half-flashed device. NoopFirmwareFileHandler defaults to inert rather than plausible — a test that forgets to override what it depends on fails instead of passing against a permissive default, which here would mean "yes, that is a bootloader volume". Co-Authored-By: Claude Opus 5 --- .../feature/firmware/UsbMaintenance.kt | 103 +++++++++ .../firmware/CommonMaintenanceVolumeTest.kt | 208 ++++++++++++++++++ .../firmware/NoopFirmwareFileHandler.kt | 77 +++++++ .../feature/firmware/MaintenanceVolumeTest.kt | 25 +++ 4 files changed, 413 insertions(+) create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt create mode 100644 feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt 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 30d30d7118b..10e6bfb53b9 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 @@ -16,7 +16,9 @@ */ package org.meshtastic.feature.firmware +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant /** * Which leg of a multi-pass USB/UF2 sequence a file-save prompt belongs to. @@ -51,6 +53,7 @@ enum class UsbMaintenanceRequest { } /** Why a maintenance action cannot run. Surfaced as explanatory copy rather than silently hiding the action. */ +@Suppress("UndocumentedPublicProperty") enum class UsbMaintenanceRefusal { /** * The device's SoftDevice variant could not be resolved, so no erase image can be chosen safely. Covers an absent or @@ -63,6 +66,24 @@ enum class UsbMaintenanceRefusal { /** No release firmware is selected, so there would be nothing to re-flash after erasing. */ NoFirmwareRelease, + + /** The picked destination is not on removable storage, so it cannot be a mounted bootloader volume. */ + DestinationNotRemovable, + + /** + * The picked volume exposes no readable `INFO_UF2.TXT`, so it is not an Adafruit-family UF2 bootloader drive. Also + * the outcome when a device rebooted into CDC-only bootloader mode, where no mass-storage volume exists at all. + */ + NotABootloaderVolume, + + /** + * The volume's reported SoftDevice contradicts the bundled map. One of the two is wrong and we cannot tell which, so + * refuse — and treat the map row as suspect. + */ + SoftDeviceConflict, + + /** No OTAFIX image is published for the Board-ID this volume reports. */ + UnknownBoardId, } /** @@ -114,3 +135,85 @@ internal fun usbMaintenanceGate( showBootloaderUpgrade = hardware.isNrf52Arc && otafixSupportsTarget(hardware.effectiveTarget), ) } + +/** + * What a mounted UF2 bootloader volume says about itself, read from its `INFO_UF2.TXT`. + * + * @property boardId The `Board-ID:` line — unique per board, and stable across bootloader vintages. + * @property softDevice The installed SoftDevice, when the bootloader reports one. `null` on RP2040 (no SoftDevice + * exists) and on bootloaders predating the `uf2_init` that appends the line. + */ +internal data class MaintenanceVolume(val boardId: String, val softDevice: SoftDeviceVariant?) + +/** Outcome of vetting a user-picked volume before anything is written to it. */ +internal sealed interface VolumeInspection { + data class Accepted(val volume: MaintenanceVolume) : VolumeInspection + + data class Rejected(val reason: UsbMaintenanceRefusal) : VolumeInspection +} + +/** + * Vets [treeUri] as a UF2 bootloader volume before it is written to. + * + * Two independent checks, cheapest first: the volume must be removable, and it must expose a readable `INFO_UF2.TXT` + * with a `Board-ID:` line. The second is the load-bearing one — it is positive proof of an Adafruit-family bootloader + * drive, where a removability heuristic only makes "somewhere on internal storage" less likely. This is what stops the + * common mis-tap (saving into Downloads) from being indistinguishable from a successful flash. + */ +internal suspend fun inspectMaintenanceVolume( + treeUri: CommonUri, + fileHandler: FirmwareFileHandler, +): VolumeInspection { + if (!fileHandler.isRemovableDestination(treeUri)) { + return VolumeInspection.Rejected(UsbMaintenanceRefusal.DestinationNotRemovable) + } + val info = + fileHandler.readSiblingText(treeUri, INFO_UF2_FILE_NAME) + ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume) + val boardId = + parseUf2BoardId(info) ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume) + + return VolumeInspection.Accepted(MaintenanceVolume(boardId = boardId, softDevice = parseUf2SoftDevice(info))) +} + +/** Which image to write, or why not. */ +internal sealed interface MaintenanceImageChoice { + data class Resolved(val asset: MaintenanceUf2) : MaintenanceImageChoice + + data class Refused(val reason: UsbMaintenanceRefusal) : MaintenanceImageChoice +} + +/** + * Chooses the image for [request], preferring what the mounted [volume] reports over what the bundled map predicted. + * + * Total over both requests and every refusal, with no default image on any path. Called only after + * [inspectMaintenanceVolume] accepts, and always before anything is written — so a refusal here costs the user a + * message, never a half-flashed device. + */ +internal fun chooseMaintenanceImage( + request: UsbMaintenanceRequest, + hardware: DeviceHardware, + volume: MaintenanceVolume, +): MaintenanceImageChoice = when (request) { + UsbMaintenanceRequest.FactoryErase -> + if (hardware.isNrf52Arc) { + when (val resolution = resolveNrfEraseImage(hardware.softDeviceVariant, volume.softDevice)) { + is EraseImageResolution.Resolved -> MaintenanceImageChoice.Resolved(resolution.asset) + is EraseImageResolution.Conflict -> + MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.SoftDeviceConflict) + + EraseImageResolution.Unresolved -> + MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnknownSoftDevice) + } + } else { + // RP2040: pico_erase is board-agnostic and there is no SoftDevice to reconcile. + eraseUf2For(hardware) + ?.let { MaintenanceImageChoice.Resolved(it) } + ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnsupportedArchitecture) + } + + UsbMaintenanceRequest.BootloaderUpgrade -> + otafixUf2ForBoardId(volume.boardId) + ?.let { MaintenanceImageChoice.Resolved(it) } + ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnknownBoardId) +} 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 new file mode 100644 index 00000000000..619dc061886 --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt @@ -0,0 +1,208 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Covers volume vetting and image choice — the two decisions that gate every destructive write. + * + * Abstract because it builds [CommonUri], whose Android `actual` needs Robolectric; the concrete `jvmTest` subclass + * supplies the runner. Without that subclass these tests would silently not run at all. + */ +abstract class CommonMaintenanceVolumeTest { + + private val treeUri = CommonUri.parse("content://com.android.externalstorage.documents/tree/1234-5678%3A") + + private fun nrf(variant: SoftDeviceVariant?) = DeviceHardware( + hwModelSlug = "RAK4631", + platformioTarget = "rak4631", + architecture = "nrf52840", + softDeviceVariant = variant, + ) + + private fun rp2040() = + DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040") + + private val rakInfo = + "UF2 Bootloader 0.4.3\r\nModel: WisBlock RAK4631 Board\r\n" + + "Board-ID: WisBlock-RAK4631-Board\r\nDate: May 20 2023\r\nSoftDevice: S140 6.1.1\r\n" + + /** RP2040 BOOTSEL volumes expose an INFO_UF2.TXT too, with no SoftDevice line. */ + private val picoInfo = "UF2 Bootloader v3.0\r\nModel: Raspberry Pi RP2\r\nBoard-ID: RPI-RP2\r\n" + + private class FakeVolume( + private val removable: Boolean = true, + private val info: String? = null, + ) : NoopFirmwareFileHandler() { + var readCount = 0 + private set + + override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = removable + + override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? { + readCount++ + return info.takeIf { fileName.equals(INFO_UF2_FILE_NAME, ignoreCase = true) } + } + } + + // ── Volume vetting ─────────────────────────────────────────────────────── + + @Test + fun `a removable volume exposing INFO_UF2 is accepted with its reported identity`() = runTest { + val result = inspectMaintenanceVolume(treeUri, FakeVolume(info = rakInfo)) + + val accepted = assertIs(result) + assertEquals("WisBlock-RAK4631-Board", accepted.volume.boardId) + assertEquals(SoftDeviceVariant.S140_6_1_1, accepted.volume.softDevice) + } + + @Test + fun `an rp2040 bootsel volume is accepted with no softdevice`() = runTest { + val accepted = assertIs(inspectMaintenanceVolume(treeUri, FakeVolume(info = picoInfo))) + + assertEquals("RPI-RP2", accepted.volume.boardId) + assertEquals(null, accepted.volume.softDevice, "RP2040 has no SoftDevice to report") + } + + @Test + fun `internal storage is rejected before INFO_UF2 is even read`() = runTest { + // This is the Downloads mis-tap. Rejecting on the cheap check first means no I/O against a wrong volume. + val fake = FakeVolume(removable = false, info = rakInfo) + + val rejected = assertIs(inspectMaintenanceVolume(treeUri, fake)) + + assertEquals(UsbMaintenanceRefusal.DestinationNotRemovable, rejected.reason) + assertEquals(0, fake.readCount, "Must not read from a volume already known to be wrong") + } + + @Test + fun `a removable volume without INFO_UF2 is not a bootloader drive`() = runTest { + // Also the CDC-only bootloader case, where no mass-storage volume exists at all. + val rejected = assertIs(inspectMaintenanceVolume(treeUri, FakeVolume(info = null))) + + assertEquals(UsbMaintenanceRefusal.NotABootloaderVolume, rejected.reason) + } + + @Test + fun `an INFO_UF2 without a board id is not a bootloader drive`() = runTest { + val fake = FakeVolume(info = "UF2 Bootloader 0.4.3\r\nModel: Something\r\n") + + val rejected = assertIs(inspectMaintenanceVolume(treeUri, fake)) + + assertEquals(UsbMaintenanceRefusal.NotABootloaderVolume, rejected.reason) + } + + // ── Image choice ───────────────────────────────────────────────────────── + + @Test + fun `erase uses the volume's softdevice when it agrees with the map`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.FactoryErase, + nrf(SoftDeviceVariant.S140_6_1_1), + MaintenanceVolume("WisBlock-RAK4631-Board", SoftDeviceVariant.S140_6_1_1), + ) + + assertEquals("nrf_erase2.uf2", assertIs(choice).asset.fileName) + } + + @Test + fun `erase refuses when the volume and the map disagree`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.FactoryErase, + nrf(SoftDeviceVariant.S140_6_1_1), + MaintenanceVolume("WisBlock-RAK4631-Board", SoftDeviceVariant.S140_7_3_0), + ) + + assertEquals( + UsbMaintenanceRefusal.SoftDeviceConflict, + assertIs(choice).reason, + "A disagreement must refuse — writing either image risks the SoftDevice", + ) + } + + @Test + fun `erase falls back to the map when the volume reports no softdevice`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.FactoryErase, + nrf(SoftDeviceVariant.S140_7_3_0), + MaintenanceVolume("SomeBoard", softDevice = null), + ) + + assertEquals("nrf_erase_sd7_3.uf2", assertIs(choice).asset.fileName) + } + + @Test + fun `erase refuses when neither the volume nor the map knows the softdevice`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.FactoryErase, + nrf(variant = null), + MaintenanceVolume("SomeBoard", softDevice = null), + ) + + assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, assertIs(choice).reason) + } + + @Test + fun `erase on rp2040 resolves without consulting a softdevice`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.FactoryErase, + rp2040(), + MaintenanceVolume("RPI-RP2", softDevice = null), + ) + + assertEquals("pico_erase.uf2", assertIs(choice).asset.fileName) + } + + @Test + fun `bootloader upgrade resolves from the volume's board id, not the build target`() { + // Hardware reports itself as a T-Echo while the catalog target says rak4631: the volume wins, because it is the + // thing physically in front of us. + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.BootloaderUpgrade, + nrf(SoftDeviceVariant.S140_6_1_1), + MaintenanceVolume("nRF52840-TEcho-v1", SoftDeviceVariant.S140_6_1_1), + ) + + assertTrue(assertIs(choice).asset.fileName.contains("lilygo_techo")) + } + + @Test + fun `bootloader upgrade refuses an unrecognized board id`() { + val choice = + chooseMaintenanceImage( + UsbMaintenanceRequest.BootloaderUpgrade, + nrf(SoftDeviceVariant.S140_6_1_1), + MaintenanceVolume("RPI-RP2", softDevice = null), + ) + + assertEquals(UsbMaintenanceRefusal.UnknownBoardId, assertIs(choice).reason) + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt new file mode 100644 index 00000000000..6d152d4df6c --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt @@ -0,0 +1,77 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.model.DeviceHardware + +/** + * A do-nothing [FirmwareFileHandler] for tests to subclass, overriding only what they exercise. + * + * Hand-rolled rather than a mocking framework, matching the convention in `CommonPerformUsbUpdateTest` — this module's + * shared tests run on KMP targets where mokkery is not available everywhere. + * + * Defaults are chosen to be inert rather than plausible: nothing exists, nothing is removable, nothing is readable. A + * test that forgets to override the method it depends on fails rather than accidentally passing against a permissive + * default — which matters here, where a permissive default would mean "yes, that's a bootloader volume". + */ +abstract class NoopFirmwareFileHandler : FirmwareFileHandler { + override fun cleanupAllTemporaryFiles() = Unit + + override suspend fun deleteFile(file: FirmwareArtifact) = Unit + + override suspend fun checkUrlExists(url: String): Boolean = false + + override suspend fun fetchText(url: String): String? = null + + override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): FirmwareArtifact? = + null + + override suspend fun getFileSize(file: FirmwareArtifact): Long = 0L + + override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray = ByteArray(0) + + override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = null + + override suspend fun getDisplayName(uri: CommonUri): String? = null + + override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long = 0L + + override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = false + + override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = false + + override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = null + + override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? = null + + override suspend fun extractFirmware( + uri: CommonUri, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String?, + ): FirmwareArtifact? = null + + override suspend fun extractFirmwareFromZip( + zipFile: FirmwareArtifact, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String?, + ): FirmwareArtifact? = null + + override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map = emptyMap() +} diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt new file mode 100644 index 00000000000..90179535638 --- /dev/null +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ +package org.meshtastic.feature.firmware + +/** + * JVM runner for [CommonMaintenanceVolumeTest]. + * + * The base class is abstract because it builds `CommonUri`. Without a concrete subclass it would contribute zero tests + * silently — see PR #6501, which revived eight bases that had been inert for exactly this reason. + */ +class MaintenanceVolumeTest : CommonMaintenanceVolumeTest() From 61bf887de0abe6c139c9635afaecf0e033c75ea2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 09:03:18 -0500 Subject: [PATCH 10/19] feat(firmware): drive the multi-pass USB maintenance sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires erase and bootloader upgrade end to end, less the Ready-state UI. Sequencing. performUsbMaintenance downloads and verifies the release firmware, reboots to DFU, and publishes the first pass. The ordering is deliberate and differs from the original plan: the firmware is fetched before rebooting because it is what restores the device after a destructive write, but the maintenance image cannot be — it is chosen from the Board-ID and SoftDevice the mounted volume reports, and no volume exists until the device has rebooted. It is fetched after the volume is vetted and still before any write, so the property that matters holds: never write a destructive image without already holding the firmware to put back. AwaitingFileSave now carries the step and an optional retry message, and its artifact is nullable because a maintenance pass genuinely has no image yet. Each pass re-picks the volume, since the device re-enumerates between passes and the previous grant no longer refers to the mounted drive. The instruction dialog is keyed on the step, so pass two shows its own instructions rather than silently rendering a bare button. No abort edge after the first destructive write: once the device has no application, a failure re-offers the same pass with an explanation instead of dropping the user on an error screen. Before that point failures surface normally. UsbRepository.pokeDtr opens a port, asserts DTR and closes, with no reader thread and no listener — the erase image blocks in while(!Serial) before formatting and there is no Meshtastic protocol on the other end. The port is identified by diffing against a snapshot taken before the write, because bootloader-mode VID/PIDs collide across boards and serial numbers need a permission grant we may not hold. Permission is requested in-flow, since device_filter.xml lists no bootloader ids at all. FirmwareRetriever becomes open so tests can double it, matching FirmwareRecoveryDataSource's existing precedent. Co-Authored-By: Claude Opus 5 --- .skills/compose-ui/strings-index.txt | 17 ++ .../core/network/repository/UsbRepository.kt | 42 +++ .../composeResources/values/strings.xml | 17 ++ feature/firmware/build.gradle.kts | 8 +- .../firmware/AndroidFirmwareUsbManager.kt | 37 ++- .../feature/firmware/FirmwareRetriever.kt | 10 +- .../feature/firmware/FirmwareUpdateActions.kt | 4 + .../feature/firmware/FirmwareUpdateScreen.kt | 53 +++- .../feature/firmware/FirmwareUpdateState.kt | 25 +- .../firmware/FirmwareUpdateViewModel.kt | 185 +++++++++++- .../feature/firmware/FirmwareUsbManager.kt | 21 ++ .../feature/firmware/UsbUpdateSupport.kt | 270 ++++++++++++++++++ .../firmware/CommonPerformUsbUpdateTest.kt | 3 +- .../firmware/FirmwareUpdateIntegrationTest.kt | 2 + .../firmware/FirmwareUpdateViewModelTest.kt | 2 + .../firmware/DesktopFirmwareUsbManager.kt | 8 + .../FirmwareUpdateViewModelFileTest.kt | 2 + 17 files changed, 688 insertions(+), 18 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index b87add6771b..dde7e6d3b42 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -609,6 +609,23 @@ firmware firmware_edition firmware_event_ended_banner firmware_event_ended_button +firmware_maintenance_cdc_unblock_failed +firmware_maintenance_copy_failed +firmware_maintenance_erase_action +firmware_maintenance_erase_confirm_button +firmware_maintenance_erase_confirm_text +firmware_maintenance_erase_confirm_title +firmware_maintenance_no_release +firmware_maintenance_not_a_bootloader_volume +firmware_maintenance_select_drive +firmware_maintenance_softdevice_conflict +firmware_maintenance_unknown_board +firmware_maintenance_unknown_softdevice +firmware_maintenance_unsupported_device +firmware_maintenance_upgrade_bootloader_action +firmware_maintenance_upgrade_confirm_text +firmware_maintenance_upgrade_confirm_title +firmware_maintenance_wrong_destination firmware_old firmware_recovery_banner firmware_recovery_ble_failed diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index 489ba1990ea..148ece1a950 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -21,9 +21,12 @@ import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import co.touchlab.kermit.Logger import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialProber import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,9 +37,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ignoreException import org.meshtastic.core.common.util.registerReceiverCompat import org.meshtastic.core.di.CoroutineDispatchers +/** Any standard rate works for a DTR poke — the erase firmware only observes the line state, never the baud. */ +private const val POKE_BAUD_RATE = 115200 + +private const val DATA_BITS_8 = 8 + /** Repository responsible for maintaining and updating the state of USB connectivity. */ @OptIn(ExperimentalCoroutinesApi::class) @Single @@ -91,6 +100,39 @@ class UsbRepository( fun requestPermission(device: UsbDevice): Flow = usbManagerLazy.value?.requestPermission(application, device) ?: emptyFlow() + /** True when the app already holds USB permission for [device], so a request round-trip can be skipped. */ + fun hasPermission(device: UsbDevice): Boolean = usbManagerLazy.value?.hasPermission(device) == true + + /** + * Opens [driver]'s port, asserts DTR/RTS for [holdMillis], and closes it again. + * + * Exists because some firmware waits for a host before it will run: the nRF52 factory-erase image blocks in + * `while (!Serial)` before formatting, and `Serial` only becomes truthy once DTR is asserted. Deliberately does not + * go through [createSerialConnection], which welds the assertion to a `SerialInputOutputManager` reader thread and a + * mandatory listener — there is no Meshtastic protocol on the other end here, just a blocked `setup()`. + * + * @return true when the port was opened and DTR held; false if permission is missing or the port could not be opened. + */ + suspend fun pokeDtr(driver: UsbSerialDriver, holdMillis: Long): Boolean = withContext(dispatchers.io) { + val manager = usbManagerLazy.value ?: return@withContext false + val port = driver.ports.firstOrNull() ?: return@withContext false + val connection = manager.openDevice(driver.device) ?: return@withContext false + + try { + port.open(connection) + port.setParameters(POKE_BAUD_RATE, DATA_BITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + port.dtr = true + port.rts = true + delay(holdMillis) + true + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "DTR poke failed for ${driver.device.usbSerialStableKey()}" } + false + } finally { + ignoreException(silent = true) { port.close() } + } + } + fun refreshState() { processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 4be92ff63bf..1c41ac6f084 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -636,6 +636,23 @@ Firmware Edition %1$s has ended. Return to standard Meshtastic firmware to restore normal features. Update firmware + 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. + Erase and reinstall + Erase device + This erases everything on %1$s, including its channels, keys and all settings, then reinstalls the firmware. It cannot be undone and there is no backup.\n\nYou will be asked to select the device\'s drive twice: once for the erase file, then again for the firmware. + Erase this device? + Select a firmware version first, so the device can be reinstalled after erasing. + That drive isn\'t the device\'s update drive. Select the drive that appears when the device is in update mode (it contains a file named INFO_UF2.TXT). + Select the device\'s update drive + The device reports a different Bluetooth stack than expected, so erasing it isn\'t safe. Please report this along with your device model. + This device\'s bootloader isn\'t one this app has an upgrade for. + This app can\'t confirm which Bluetooth stack this device uses, so erasing it isn\'t safe. Use the web flasher at flasher.meshtastic.org instead. + This device doesn\'t support erasing from the app. + Upgrade bootloader + This installs an upgraded bootloader on %1$s, which makes future Bluetooth updates much faster. The firmware is reinstalled afterwards.\n\nDon\'t disconnect the device while this runs. + Upgrade bootloader? + That location isn\'t the device\'s update drive, so nothing was written to the device. Select the removable drive that appears when the device is in update mode. The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide. Finish updating %1$s 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. diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index 85fa8eab2f2..6180386b348 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -51,7 +51,13 @@ kotlin { implementation(libs.markdown.renderer.m3) } - androidMain.dependencies { implementation(libs.markdown.renderer.android) } + androidMain.dependencies { + implementation(libs.markdown.renderer.android) + // AndroidFirmwareUsbManager needs UsbSerialDriver to poke DTR on the erase image's CDC port. + // :core:network exposes usb-serial as `implementation`, so it is not on our classpath transitively — + // feature/connections declares it the same way for the same reason. + implementation(libs.usb.serial.android) + } // performUsbUpdate resolves compose-resources strings, whose desktop implementation needs // the skiko-awt runtime to read the system theme. diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt index efe43d6ad8e..ad5095bc0e3 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt @@ -26,11 +26,46 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Single +import org.meshtastic.core.network.repository.UsbRepository /** Manages USB-related interactions for firmware updates. */ @Single -class AndroidFirmwareUsbManager(private val context: Context) : FirmwareUsbManager { +class AndroidFirmwareUsbManager(private val context: Context, private val usbRepository: UsbRepository) : + FirmwareUsbManager { + + override suspend fun serialPortKeys(): Set = usbRepository.serialDevices.value.keys + + override suspend fun unblockCdcPort(excluding: Set, waitMillis: Long, holdMillis: Long): Boolean { + // The erase image enumerates a moment after the write, so poll rather than sampling once. + val driver = + withTimeoutOrNull(waitMillis) { + usbRepository.serialDevices + .map { devices -> devices.entries.firstOrNull { it.key !in excluding }?.value } + .filterNotNull() + .first() + } + + if (driver == null) { + Logger.w { "No new serial port appeared within ${waitMillis}ms; cannot unblock the erase image" } + return false + } + + // device_filter.xml lists no bootloader-mode ids (and none at all for Seeed's 0x2886), so no implicit grant + // exists here — an explicit request is the only route. + if (!usbRepository.hasPermission(driver.device) && + usbRepository.requestPermission(driver.device).first() != true + ) { + Logger.w { "USB permission denied; cannot unblock the erase image" } + return false + } + + return usbRepository.pokeDtr(driver, holdMillis) + } /** Observe when a USB device is detached. */ override fun deviceDetachFlow(): Flow = callbackFlow { val receiver = diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index a02653e6302..052bd9e0355 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -40,14 +40,14 @@ private val manifestJson = Json { /** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */ @Single -class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { +open class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { /** * Download the OTA firmware zip for a Nordic (nRF52) DFU update. * * @return The downloaded `-ota.zip` [FirmwareArtifact], or `null` if the file could not be resolved. */ - suspend fun retrieveOtaFirmware( + open suspend fun retrieveOtaFirmware( release: FirmwareRelease, hardware: DeviceHardware, onProgress: (Float) -> Unit, @@ -64,7 +64,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { * * @return The downloaded `.uf2` [FirmwareArtifact], or `null` if the file could not be resolved. */ - suspend fun retrieveUsbFirmware( + open suspend fun retrieveUsbFirmware( release: FirmwareRelease, hardware: DeviceHardware, onProgress: (Float) -> Unit, @@ -88,7 +88,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { * * @return The verified [FirmwareArtifact], or `null` when the download failed or verification did not pass. */ - internal suspend fun retrieveMaintenanceUf2(asset: MaintenanceUf2, onProgress: (Float) -> Unit): FirmwareArtifact? { + internal open suspend fun retrieveMaintenanceUf2(asset: MaintenanceUf2, onProgress: (Float) -> Unit): FirmwareArtifact? { val artifact = try { fileHandler.downloadFile(asset.url, asset.fileName, onProgress) @@ -137,7 +137,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { * @return The downloaded `.bin` [FirmwareArtifact], or `null` if the file could not be resolved. */ @Suppress("ReturnCount") - suspend fun retrieveEsp32Firmware( + open suspend fun retrieveEsp32Firmware( release: FirmwareRelease, hardware: DeviceHardware, onProgress: (Float) -> Unit, 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 aa65f43a732..1ace580d6fa 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 @@ -23,6 +23,10 @@ data class FirmwareUpdateActions( val onStartUpdate: () -> Unit, val onPickFile: () -> Unit, val onSaveFile: (String) -> Unit, + /** Pick the device's UF2 volume for a maintenance pass, which vets the drive before writing to it. */ + val onPickVolume: () -> Unit, + val onFactoryErase: () -> Unit, + val onBootloaderUpgrade: () -> 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 baae7aaf6d0..71443cc0b9b 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,7 @@ 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_select_drive 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 @@ -155,6 +156,7 @@ import org.meshtastic.core.ui.icon.Usb import org.meshtastic.core.ui.icon.Warning import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.KeepScreenOn +import org.meshtastic.core.ui.util.rememberOpenDocumentTreeLauncher import org.meshtastic.core.ui.util.rememberOpenFileLauncher import org.meshtastic.core.ui.util.rememberOpenUrl import org.meshtastic.core.ui.util.rememberSaveFileLauncher @@ -188,6 +190,10 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) } + // Maintenance passes pick the whole volume, not a filename, so the app can read INFO_UF2.TXT and confirm it really + // is the device's update drive before writing anything to it. + val volumePickerLauncher = rememberOpenDocumentTreeLauncher { uri -> uri?.let { viewModel.writeMaintenancePass(it) } } + val actions = remember(viewModel, onNavigateUp) { FirmwareUpdateActions( @@ -198,7 +204,10 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView filePickerLauncher("*/*") } }, - onSaveFile = { fileName -> saveFileLauncher(fileName, "application/octet-stream") }, + onSaveFile = { fileName -> saveFileLauncher(fileName, UF2_MIME_TYPE) }, + onPickVolume = volumePickerLauncher, + onFactoryErase = viewModel::startFactoryErase, + onBootloaderUpgrade = viewModel::startBootloaderUpgrade, onConfirmLocalFile = viewModel::confirmLocalFirmwareFile, onDismissLocalFile = viewModel::dismissLocalFirmwareFile, onRetry = viewModel::checkForUpdates, @@ -410,7 +419,8 @@ private fun FirmwareUpdateContent( is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone, wasLowSpeedTransfer = state.wasLowSpeedTransfer) - is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile) + is FirmwareUpdateState.AwaitingFileSave -> + AwaitingFileSaveState(state = state, onSaveFile = actions.onSaveFile, onPickVolume = actions.onPickVolume) } } } @@ -869,8 +879,19 @@ private fun ProgressContent( } @Composable -private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, onSaveFile: (String) -> Unit) { - var showDialog by remember { mutableStateOf(true) } +internal fun AwaitingFileSaveState( + state: FirmwareUpdateState.AwaitingFileSave, + onSaveFile: (String) -> Unit, + onPickVolume: () -> Unit, +) { + // Keyed on the step so each leg of a multi-pass sequence re-shows its own instructions. An unkeyed remember would + // leave the second pass with no dialog at all, since the branch stays in composition across the transition. + var showDialog by remember(state.step) { mutableStateOf(true) } + + // A maintenance pass has no artifact yet: the image is chosen from what the volume reports, so the user points at + // the drive and the app names the file. + val fileName = state.fileName + val launchPicker = { if (fileName != null) onSaveFile(fileName) else onPickVolume() } if (showDialog) { MeshtasticDialog( @@ -879,9 +900,17 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o confirmText = stringResource(Res.string.okay), onConfirm = { showDialog = false - onSaveFile(state.fileName) + launchPicker() + }, + text = { + Text( + if (fileName != null) { + stringResource(Res.string.firmware_update_usb_instruction_text) + } else { + stringResource(Res.string.firmware_maintenance_select_drive) + }, + ) }, - text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) }, dismissable = false, ) } @@ -894,9 +923,19 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o textAlign = TextAlign.Center, ) + state.retryMessage?.let { retry -> + Spacer(Modifier.height(12.dp)) + Text( + text = retry.asString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + if (!showDialog) { Spacer(Modifier.height(16.dp)) - Button(onClick = { onSaveFile(state.fileName) }) { Text(stringResource(Res.string.save)) } + Button(onClick = launchPicker) { Text(stringResource(Res.string.save)) } } } 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 b765c6f00ef..ed6b40242b9 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 @@ -57,6 +57,11 @@ sealed interface FirmwareUpdateState { * update (see [FirmwareUpdateViewModel.checkForUpdates]). Drives recovery-specific copy and routing. */ val isRecovery: Boolean = false, + /** + * Which USB maintenance affordances to show, precomputed by the ViewModel so the screen stays dumb and the + * decision stays unit-testable — the same shape as [showBootloaderWarning]. + */ + val maintenance: UsbMaintenanceGate = UsbMaintenanceGate(), ) : FirmwareUpdateState /** Firmware file is being downloaded from the release server. */ @@ -85,8 +90,24 @@ sealed interface FirmwareUpdateState { */ 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 + /** + * Waiting for the user to point the app at the device's UF2 drive (USB flow). + * + * @property uf2Artifact The image to write, when it is already downloaded and verified. `null` on a maintenance pass: + * erase and bootloader images are chosen from what the mounted volume reports about itself, so the image cannot be + * known until the volume has been picked. + * @property fileName Display name of [uf2Artifact], or `null` for the same reason. + * @property step Which leg of a multi-pass sequence this is. Also the recomposition key for the instruction dialog, + * so each pass re-shows its own instructions. + * @property retryMessage Set when a previous attempt at *this* pass failed. Once a destructive image has been + * written the flow never abandons the user on an error screen — it re-publishes the same pass with an explanation. + */ + data class AwaitingFileSave( + val uf2Artifact: FirmwareArtifact?, + val fileName: String?, + val step: UsbFileSaveStep = UsbFileSaveStep.Firmware, + val retryMessage: UiText? = null, + ) : 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 79d99dd2048..2e2419fe2c6 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 @@ -62,6 +62,15 @@ 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_maintenance_cdc_unblock_failed +import org.meshtastic.core.resources.firmware_maintenance_copy_failed +import org.meshtastic.core.resources.firmware_maintenance_no_release +import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume +import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict +import org.meshtastic.core.resources.firmware_maintenance_unknown_board +import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice +import org.meshtastic.core.resources.firmware_maintenance_unsupported_device +import org.meshtastic.core.resources.firmware_maintenance_wrong_destination import org.meshtastic.core.resources.firmware_recovery_ble_failed import org.meshtastic.core.resources.firmware_update_archive_missing_target import org.meshtastic.core.resources.firmware_update_battery_low @@ -81,6 +90,7 @@ import org.meshtastic.core.resources.firmware_update_node_info_missing import org.meshtastic.core.resources.firmware_update_requires_bin import org.meshtastic.core.resources.firmware_update_requires_ota_zip import org.meshtastic.core.resources.firmware_update_requires_uf2 +import org.meshtastic.core.resources.firmware_update_retrieval_failed import org.meshtastic.core.resources.firmware_update_unknown_error import org.meshtastic.core.resources.firmware_update_unknown_hardware import org.meshtastic.core.resources.firmware_update_unsupported_update_method @@ -111,6 +121,7 @@ class FirmwareUpdateViewModel( private val firmwareUpdateManager: FirmwareUpdateManager, private val usbManager: FirmwareUsbManager, private val fileHandler: FirmwareFileHandler, + private val firmwareRetriever: FirmwareRetriever, private val applicationScope: ApplicationCoroutineScope, private val hiddenFeaturesUnlock: HiddenFeaturesUnlock, ) : ViewModel() { @@ -147,6 +158,18 @@ class FirmwareUpdateViewModel( /** Set when [checkForUpdates] enters recovery mode (disconnected + a saved record); consumed by [startUpdate]. */ private var pendingRecovery: PendingFirmwareRecovery? = null + /** Remaining legs of an in-flight USB maintenance sequence, head first. Empty outside a maintenance flow. */ + private var pendingUsbPasses: List = emptyList() + + /** Hardware the running maintenance sequence targets; needed to choose images once a volume is read. */ + private var maintenanceHardware: DeviceHardware? = 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. + */ + private var destructiveWriteDone = false + init { // Cleanup potential leftovers viewModelScope.launch { @@ -254,6 +277,12 @@ class FirmwareUpdateViewModel( radioPrefs.isBle(), updateMethod = firmwareUpdateMethod, currentFirmwareVersion = ourNode.firmwareVersion, + maintenance = + usbMaintenanceGate( + hardware = deviceHardware, + updateMethod = firmwareUpdateMethod, + hasRelease = release != null, + ), ) } } @@ -460,9 +489,163 @@ class FirmwareUpdateViewModel( } } + // ── USB maintenance (factory erase / bootloader upgrade) ──────────────────────────────────────── + + fun startFactoryErase() = startUsbMaintenance(UsbMaintenanceRequest.FactoryErase) + + fun startBootloaderUpgrade() = startUsbMaintenance(UsbMaintenanceRequest.BootloaderUpgrade) + + private fun startUsbMaintenance(request: UsbMaintenanceRequest) { + val currentState = _state.value as? FirmwareUpdateState.Ready ?: return + val release = currentState.release ?: return + + // Defence in depth: the screen already disables a refused erase, but never start one on a device whose + // SoftDevice we could not resolve. + val refusal = currentState.maintenance.eraseRefusal + if (request == UsbMaintenanceRequest.FactoryErase && refusal != null) { + _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalError(refusal)) + return + } + + originalDeviceAddress = radioPrefs.devAddr.value + maintenanceHardware = currentState.deviceHardware + destructiveWriteDone = false + + viewModelScope.launch { + if (!checkBatteryLevel()) return@launch + updateJob?.cancel() + updateJob = + viewModelScope.launch { + try { + pendingUsbPasses = + performUsbMaintenance( + request = request, + release = release, + hardware = currentState.deviceHardware, + radioController = radioController, + nodeRepository = nodeRepository, + updateState = { _state.value = it }, + retrieveUsbFirmware = firmwareRetriever::retrieveUsbFirmware, + ) + // The firmware image is the last pass, so it is also what must be cleaned up if the flow dies. + tempFirmwareFile = + pendingUsbPasses.filterIsInstance().lastOrNull()?.artifact + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "USB maintenance preparation failed" } + _state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed)) + } + } + } + } + + /** + * Resumes a maintenance sequence once the user has pointed at the device's UF2 volume. + * + * Distinct from [saveDfuFile] because the URI kinds differ: this takes a *tree* URI, which grants the sibling access + * needed to read `INFO_UF2.TXT` and vet the volume before writing. Each pass re-picks, because the device + * re-enumerates between passes and the previous grant no longer refers to the mounted volume. + */ + fun writeMaintenancePass(treeUri: CommonUri) { + val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return + val pass = pendingUsbPasses.firstOrNull() ?: return + if (pass.step != currentState.step) return + val hardware = maintenanceHardware ?: return + + viewModelScope.launch { + try { + // Capture the ports present before the write so the erase image's port can be told from pre-existing ones. + val portsBefore = usbManager.serialPortKeys() + val result = + usbPassWriter(portsBefore).write(pass, treeUri, hardware) { _state.value = it } + handlePassResult(pass, result) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "Writing ${pass.step} failed" } + reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_failed)) + } + } + } + + private suspend fun handlePassResult(pass: UsbFileSavePass, result: UsbPassResult) = when (result) { + UsbPassResult.Written -> advancePastPass(pass) + + is UsbPassResult.Refused -> reofferOrFail(pass, usbMaintenanceRefusalError(result.reason)) + + UsbPassResult.ImageDownloadFailed -> + reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_retrieval_failed)) + + UsbPassResult.CopyFailed -> reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_copy_failed)) + + UsbPassResult.WriteDidNotLand -> + reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_wrong_destination)) + + UsbPassResult.CdcUnblockFailed -> + reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_cdc_unblock_failed)) + } + + private suspend fun advancePastPass(pass: UsbFileSavePass) { + if (pass.step.isDestructive) destructiveWriteDone = true + pendingUsbPasses = pendingUsbPasses.drop(1) + + val next = pendingUsbPasses.firstOrNull() + if (next == null) { + verifyUpdateResult(originalDeviceAddress) + } else { + _state.value = next.toAwaitingFileSave() + } + } + + /** + * Re-offers the same pass with an explanation, or fails outright when nothing destructive has happened yet. + * + * Once an erase or bootloader image has been written the device has no application, so dropping the user on an error + * screen is the worst available outcome — the flow keeps offering the pass until it succeeds or they leave + * deliberately. + */ + private fun reofferOrFail(pass: UsbFileSavePass, message: UiText) { + _state.value = + if (destructiveWriteDone) pass.toAwaitingFileSave(retryMessage = message) + else FirmwareUpdateState.Error(message) + } + + private fun usbPassWriter(portsBefore: Set) = UsbPassWriter( + fileHandler = fileHandler, + retrieveMaintenanceUf2 = { asset, onProgress -> firmwareRetriever.retrieveMaintenanceUf2(asset, onProgress) }, + awaitDeviceDetach = { timeout -> + withTimeoutOrNull(timeout) { usbManager.deviceDetachFlow().first() } != null + }, + unblockCdc = { wait, hold -> usbManager.unblockCdcPort(portsBefore, wait, hold) }, + ) + + private fun usbMaintenanceRefusalError(reason: UsbMaintenanceRefusal): UiText = when (reason) { + UsbMaintenanceRefusal.UnknownSoftDevice -> + UiText.Resource(Res.string.firmware_maintenance_unknown_softdevice) + + UsbMaintenanceRefusal.UnsupportedArchitecture -> + UiText.Resource(Res.string.firmware_maintenance_unsupported_device) + + UsbMaintenanceRefusal.NoFirmwareRelease -> UiText.Resource(Res.string.firmware_maintenance_no_release) + + UsbMaintenanceRefusal.DestinationNotRemovable -> + UiText.Resource(Res.string.firmware_maintenance_wrong_destination) + + UsbMaintenanceRefusal.NotABootloaderVolume -> + UiText.Resource(Res.string.firmware_maintenance_not_a_bootloader_volume) + + UsbMaintenanceRefusal.SoftDeviceConflict -> + UiText.Resource(Res.string.firmware_maintenance_softdevice_conflict) + + UsbMaintenanceRefusal.UnknownBoardId -> + UiText.Resource(Res.string.firmware_maintenance_unknown_board) + } + fun saveDfuFile(uri: CommonUri) { val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return - val firmwareArtifact = currentState.uf2Artifact + // A maintenance pass carries no artifact — it goes through writeMaintenancePass with a tree URI instead. + val firmwareArtifact = currentState.uf2Artifact ?: return viewModelScope.launch { try { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt index d102ed4e48c..380fd61a9fd 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt @@ -20,4 +20,25 @@ import kotlinx.coroutines.flow.Flow interface FirmwareUsbManager { fun deviceDetachFlow(): Flow + + /** + * Records which serial ports are present, so a port that appears *after* a UF2 write can be told apart from one that + * was already there. + * + * Snapshot-then-diff rather than matching a VID/PID table: bootloader-mode ids are per-board and collide (four OTAFIX + * boards share `239A/0029`), and identifying by serial number needs a permission grant we may not hold yet. + */ + suspend fun serialPortKeys(): Set + + /** + * Opens the port that appeared since [excluding] was captured and holds DTR asserted, so firmware waiting on a host + * will run. + * + * The nRF52 factory-erase image blocks in `while (!Serial)` before `InternalFS.format()`, and `Serial` only becomes + * truthy once DTR is asserted — without this the erase never starts. A failure here is therefore safe: the image is + * written but has destroyed nothing. + * + * @return true when a port was claimed and DTR held. + */ + suspend fun unblockCdcPort(excluding: Set, waitMillis: Long, holdMillis: Long): Boolean } 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 d5402777201..d12e544ab51 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 @@ -26,7 +26,9 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.firmware_update_copying import org.meshtastic.core.resources.firmware_update_downloading_percent +import org.meshtastic.core.resources.firmware_update_flashing import org.meshtastic.core.resources.firmware_update_rebooting import org.meshtastic.core.resources.firmware_update_retrieval_failed import org.meshtastic.core.resources.firmware_update_usb_failed @@ -35,6 +37,274 @@ import org.meshtastic.core.resources.getStringSuspend private const val USB_REBOOT_DELAY = 5000L private const val PERCENT_MAX = 100 +/** + * One leg of a multi-pass USB/UF2 sequence. + * + * Two shapes because the passes differ in when their image becomes knowable: the application image is downloaded and + * verified up front, while an erase or bootloader image is chosen from what the mounted volume reports about itself, so + * it cannot exist until the user has pointed at the drive. + */ +internal sealed interface UsbFileSavePass { + val step: UsbFileSaveStep + + /** Image identity is decided from the mounted volume; nothing has been downloaded yet. */ + data class FromVolume( + override val step: UsbFileSaveStep, + val request: UsbMaintenanceRequest, + /** + * True for the nRF factory-erase image, which blocks on `while (!Serial)` before formatting and needs a host to + * assert DTR before it will run. + */ + val requiresCdcUnblock: Boolean, + ) : UsbFileSavePass + + /** Already downloaded and verified. */ + data class Prepared( + override val step: UsbFileSaveStep, + val artifact: FirmwareArtifact, + val fileName: String, + ) : UsbFileSavePass +} + +/** + * Prepares a USB maintenance sequence: fetch the application image, reboot into the bootloader, and publish the first + * pass. + * + * Ordering is deliberate. The **application** image is downloaded and verified *before* `rebootToDfu`, because it is + * what restores the device after a destructive write — we never erase without already holding the firmware to put back. + * The **maintenance** image cannot be fetched this early: erase and bootloader images are selected from the Board-ID and + * SoftDevice the mounted volume reports, and no volume exists until the device has rebooted. It is fetched later, after + * the volume is vetted but still before anything is written, so a failure there costs a message rather than a device. + * + * @return The ordered passes, or an empty list when preparation failed (state has already been set to an error). + */ +internal suspend fun performUsbMaintenance( + request: UsbMaintenanceRequest, + release: FirmwareRelease, + hardware: DeviceHardware, + radioController: RadioController, + nodeRepository: NodeRepository, + updateState: (FirmwareUpdateState) -> Unit, + retrieveUsbFirmware: suspend (FirmwareRelease, DeviceHardware, (Float) -> Unit) -> FirmwareArtifact?, +): List { + val downloadingMsg = getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs() + updateState( + FirmwareUpdateState.Downloading(ProgressState(message = UiText.DynamicString(downloadingMsg), progress = 0f)), + ) + + val firmware = + try { + retrieveUsbFirmware(release, hardware) { progress -> + val percent = (progress * PERCENT_MAX).toInt() + updateState( + FirmwareUpdateState.Downloading( + ProgressState( + message = UiText.DynamicString(downloadingMsg), + progress = progress, + details = "$percent%", + ), + ), + ) + } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "Maintenance firmware download failed" } + null + } + + if (firmware == null) { + updateState( + FirmwareUpdateState.Error( + UiText.DynamicString(getStringSuspend(Res.string.firmware_update_retrieval_failed)), + ), + ) + return emptyList() + } + + val maintenanceStep = + when (request) { + UsbMaintenanceRequest.FactoryErase -> UsbFileSaveStep.FactoryErase + UsbMaintenanceRequest.BootloaderUpgrade -> UsbFileSaveStep.BootloaderUpgrade + } + + updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_rebooting)))) + radioController.rebootToDfu(nodeRepository.myNodeInfo.value?.myNodeNum ?: 0) + delay(USB_REBOOT_DELAY) + + val passes = + listOf( + UsbFileSavePass.FromVolume( + step = maintenanceStep, + request = request, + // Only the nRF erase image blocks waiting for a CDC host; pico_erase runs on its own, and a bootloader + // self-update is consumed by the bootloader itself. + requiresCdcUnblock = request == UsbMaintenanceRequest.FactoryErase && hardware.isNrf52Arc, + ), + UsbFileSavePass.Prepared( + step = UsbFileSaveStep.Firmware, + artifact = firmware, + fileName = firmware.fileName ?: "firmware.uf2", + ), + ) + + updateState(passes.first().toAwaitingFileSave()) + return passes +} + +/** Outcome of attempting one pass. Every failure is typed so the caller can re-offer the same pass with copy. */ +internal sealed interface UsbPassResult { + data object Written : UsbPassResult + + /** Volume vetting or image selection said no. Nothing was written. */ + data class Refused(val reason: UsbMaintenanceRefusal) : UsbPassResult + + data object ImageDownloadFailed : UsbPassResult + + data object CopyFailed : UsbPassResult + + /** The image was copied but the destination is still readable, so it did not land on a bootloader volume. */ + data object WriteDidNotLand : UsbPassResult + + /** The erase image was written but could not be unblocked, so it never ran. Nothing was destroyed. */ + data object CdcUnblockFailed : UsbPassResult +} + +private const val DETACH_TIMEOUT_MS = 30_000L +private const val POST_WRITE_SETTLE_MS = 1_500L +private const val CDC_UNBLOCK_WAIT_MS = 15_000L +private const val CDC_DTR_HOLD_MS = 2_000L + +/** + * Writes one leg of a USB/UF2 sequence to a user-picked volume. + * + * Dependencies are constructor-injected rather than threaded through the call so the write itself keeps a short + * signature, and [unblockCdc] is a lambda so the serial plumbing can land separately from this decision logic. + * + * @property unblockCdc Opens the erase firmware's CDC port with DTR asserted so it stops waiting and formats. Returns + * false when no port could be claimed. + */ +internal class UsbPassWriter( + private val fileHandler: FirmwareFileHandler, + private val retrieveMaintenanceUf2: suspend (MaintenanceUf2, (Float) -> Unit) -> FirmwareArtifact?, + private val awaitDeviceDetach: suspend (Long) -> Boolean, + private val unblockCdc: suspend (waitMillis: Long, holdMillis: Long) -> Boolean, +) { + + /** + * Vets [treeUri], resolves the image if this pass needs it, writes, and confirms the write landed. + * + * Nothing is written until the volume has proved it is a UF2 bootloader drive and an image has been chosen and + * verified, so any refusal reaches the user with the device untouched. + */ + @Suppress("ReturnCount") + suspend fun write( + pass: UsbFileSavePass, + treeUri: CommonUri, + hardware: DeviceHardware, + updateState: (FirmwareUpdateState) -> Unit, + ): UsbPassResult { + val inspection = inspectMaintenanceVolume(treeUri, fileHandler) + val volume = + when (inspection) { + is VolumeInspection.Rejected -> return UsbPassResult.Refused(inspection.reason) + is VolumeInspection.Accepted -> inspection.volume + } + + val artifact = + when (pass) { + is UsbFileSavePass.Prepared -> pass.artifact + + is UsbFileSavePass.FromVolume -> { + when (val choice = chooseMaintenanceImage(pass.request, hardware, volume)) { + is MaintenanceImageChoice.Refused -> return UsbPassResult.Refused(choice.reason) + is MaintenanceImageChoice.Resolved -> { + val downloadingMsg = + getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs() + updateState( + FirmwareUpdateState.Downloading( + ProgressState(message = UiText.DynamicString(downloadingMsg)), + ), + ) + retrieveMaintenanceUf2(choice.asset) { progress -> + updateState( + FirmwareUpdateState.Downloading( + ProgressState( + message = UiText.DynamicString(downloadingMsg), + progress = progress, + ), + ), + ) + } ?: return UsbPassResult.ImageDownloadFailed + } + } + } + } + + val fileName = artifact.fileName ?: return UsbPassResult.CopyFailed + updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_copying)))) + + val destination = fileHandler.createDocumentInTree(treeUri, fileName, UF2_MIME_TYPE) + if (destination == null) { + Logger.w { "Could not create $fileName on the picked volume" } + return UsbPassResult.CopyFailed + } + + val copied = + runCatching { fileHandler.copyToUri(artifact, destination) } + .onFailure { Logger.e(it) { "Copying $fileName to the UF2 volume failed" } } + .getOrNull() + if (copied == null) return UsbPassResult.CopyFailed + + updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_flashing)))) + + // A UF2 bootloader consumes the image and reboots, so the volume vanishing is the success signal. Missing the + // broadcast is common and harmless — the read-back below is the authority either way. + val detached = awaitDeviceDetach(DETACH_TIMEOUT_MS) + if (!detached) { + Logger.d { "No detach observed for $fileName; falling back to the read-back check" } + delay(POST_WRITE_SETTLE_MS) + if (fileHandler.isDestinationReadable(destination)) { + // Still readable means the bytes are sitting somewhere persistent, i.e. this was never a UF2 volume. + Logger.w { "$fileName is still readable after writing — it did not land on a bootloader volume" } + return UsbPassResult.WriteDidNotLand + } + } + + if (pass is UsbFileSavePass.FromVolume && pass.requiresCdcUnblock) { + if (!unblockCdc(CDC_UNBLOCK_WAIT_MS, CDC_DTR_HOLD_MS)) { + // The erase image blocks before InternalFS.format(), so a failed unblock has destroyed nothing. + return UsbPassResult.CdcUnblockFailed + } + } + + return UsbPassResult.Written + } +} + +/** UF2 images are opaque binaries as far as the document provider is concerned. */ +internal const val UF2_MIME_TYPE = "application/octet-stream" + +/** The state that asks the user to point at the drive for this pass. */ +internal fun UsbFileSavePass.toAwaitingFileSave(retryMessage: UiText? = null): FirmwareUpdateState.AwaitingFileSave = + when (this) { + is UsbFileSavePass.FromVolume -> + FirmwareUpdateState.AwaitingFileSave( + uf2Artifact = null, + fileName = null, + step = step, + retryMessage = retryMessage, + ) + + is UsbFileSavePass.Prepared -> + FirmwareUpdateState.AwaitingFileSave( + uf2Artifact = artifact, + fileName = fileName, + step = step, + retryMessage = retryMessage, + ) + } + @Suppress("LongMethod") internal suspend fun performUsbUpdate( release: FirmwareRelease, diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt index ad643878140..cf4e3e155c3 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt @@ -112,8 +112,9 @@ abstract class CommonPerformUsbUpdateTest { ) val awaitingState = states.filterIsInstance().first() + // A plain update always carries its artifact; only maintenance passes defer the filename to the volume read. assertTrue( - awaitingState.fileName.endsWith(".uf2"), + awaitingState.fileName?.endsWith(".uf2") == true, "Expected filename to end with .uf2, got: ${awaitingState.fileName}", ) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 483cc7bb07f..c22ae33a082 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -68,6 +68,7 @@ class FirmwareUpdateIntegrationTest { private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill) private val usbManager: FirmwareUsbManager = mock(MockMode.autofill) private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill) + private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill) private val stableRelease = FirmwareRelease(id = "1", title = "2.5.0", zipUrl = "url", releaseNotes = "") private val hardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam") @@ -113,6 +114,7 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, usbManager, fileHandler, + firmwareRetriever, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), ) diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 52f34a1b9a7..f201bf5ca5f 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -76,6 +76,7 @@ class FirmwareUpdateViewModelTest { private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill) private val usbManager: FirmwareUsbManager = mock(MockMode.autofill) private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill) + private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill) private lateinit var viewModel: FirmwareUpdateViewModel @@ -136,6 +137,7 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, + firmwareRetriever, TestApplicationCoroutineScope(testDispatcher), hiddenFeaturesUnlock, ) diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt index caca9641baa..65d852d5f23 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt @@ -23,4 +23,12 @@ import org.koin.core.annotation.Single @Single class DesktopFirmwareUsbManager : FirmwareUsbManager { override fun deviceDetachFlow(): Flow = emptyFlow() + + /** + * Desktop has no UF2 maintenance flow — there is no Storage Access Framework to vet a volume with, so + * `isRemovableDestination` refuses before anything reaches here. + */ + override suspend fun serialPortKeys(): Set = emptySet() + + override suspend fun unblockCdcPort(excluding: Set, waitMillis: Long, holdMillis: Long): Boolean = false } 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 fba8e1a7fe6..3aaf7e38221 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 @@ -82,6 +82,7 @@ class FirmwareUpdateViewModelFileTest { private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill) private val usbManager: FirmwareUsbManager = mock(MockMode.autofill) private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill) + private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill) private lateinit var viewModel: FirmwareUpdateViewModel @@ -135,6 +136,7 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, + firmwareRetriever, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), ) From d2fc4450ec94392da9ea3348d21ab70955367163 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 09:29:33 -0500 Subject: [PATCH 11/19] style(firmware): satisfy the lint gate after the maintenance work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting from spotlessApply, plus fixes for every detekt finding the new code introduced. Two were real: - UsbPassWriter.write exceeded both the length and complexity limits, so image resolution is extracted into resolveImage returning Ready/Failed. That reads better than a suppression would: the write path now states its steps without the download branch inlined mid-function. - BYTE_MASK had to be 0xFFL, not 0xFF — `Long and Int` does not typecheck, which assembleDebug caught after the JVM target had already compiled. The rest are guard-clause ReturnCount suppressions on functions where an early return per failed precondition is the clear form, and named constants for the UF2 header decode and the hex radix in diagnostics. Two commonTest names lost their commas: commonTest also compiles for iOS, and Kotlin/Native rejects commas in backticked identifiers. Only `test allTests` surfaces that — the JVM target accepts them. The MultipleEmitters baseline entry for AwaitingFileSaveState is relabelled private -> internal. Same pre-existing finding; the id changed only because the composable is now internal so the upcoming preview can reach it. Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — 1512 tests, 0 failures. Co-Authored-By: Claude Opus 5 --- .../DeviceHardwareRepositoryImpl.kt | 17 +- .../core/model/BootloaderOtaQuirk.kt | 6 +- .../core/network/repository/UsbRepository.kt | 9 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 4 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 2 +- .../org/meshtastic/core/ui/util/NoopStubs.kt | 2 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 6 +- feature/firmware/detekt-baseline.xml | 2 +- .../firmware/AndroidFirmwareFileHandler.kt | 3 +- .../firmware/AndroidFirmwareUsbManager.kt | 5 +- .../feature/firmware/FirmwareRetriever.kt | 20 +- .../feature/firmware/FirmwareUpdateScreen.kt | 10 +- .../feature/firmware/FirmwareUpdateState.kt | 9 +- .../firmware/FirmwareUpdateViewModel.kt | 33 ++- .../feature/firmware/FirmwareUsbManager.kt | 8 +- .../feature/firmware/MaintenanceUf2.kt | 259 +++++++++--------- .../feature/firmware/UsbMaintenance.kt | 29 +- .../feature/firmware/UsbUpdateSupport.kt | 87 +++--- .../firmware/CommonMaintenanceVolumeTest.kt | 14 +- .../firmware/UsbMaintenanceGateTest.kt | 23 +- .../firmware/JvmFirmwareFileHandler.kt | 9 +- 21 files changed, 312 insertions(+), 245 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 832e75eb594..4ced876c327 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -198,8 +198,8 @@ class DeviceHardwareRepositoryImpl( * Deliberately stricter than [applyBootloaderQuirk] and deliberately not routed through [disambiguate]: both of * those fall back to "close enough" (`firstOrNull()`), which is right for an advisory warning and wrong here. * `hwModel` is not unique — hwModel 94 (`HELTEC_MESH_POCKET`) has two nRF52840 targets — and a plausible-but-wrong - * variant writes an erase image into the SoftDevice. Every unresolvable case (asset absent, asset malformed, - * model unmapped, reported target not in the row) therefore lands on `null`, and callers must refuse on `null`. + * variant writes an erase image into the SoftDevice. Every unresolvable case (asset absent, asset malformed, model + * unmapped, reported target not in the row) therefore lands on `null`, and callers must refuse on `null`. */ private fun applySoftDeviceVariant( hwModel: Int, @@ -207,7 +207,8 @@ class DeviceHardwareRepositoryImpl( entries: List, reportedTarget: String?, ): DeviceHardware? = base?.let { hw -> - // applyBootloaderQuirk has already overwritten platformioTarget with the reported target when one was given. + // applyBootloaderQuirk has already overwritten platformioTarget with the reported target when one was + // given. val effectiveTarget = reportedTarget?.takeIf { it.isNotBlank() } ?: hw.platformioTarget val matched = entries.firstOrNull { entry -> @@ -236,12 +237,10 @@ class DeviceHardwareRepositoryImpl( // Quirks are best-effort: swallow any parse/IO error and fall back to an empty asset rather than failing hardware // lookup. Safe for the advisory bootloader warning, and safe for the SoftDevice map too because an empty map // resolves to a null variant, which refuses. - private fun loadQuirksAsset(): BootloaderOtaQuirksResponse = runCatching { - assetReader.decode("device_bootloader_ota_quirks.json", json) - } - .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } } - .getOrNull() - ?: BootloaderOtaQuirksResponse() + private fun loadQuirksAsset(): BootloaderOtaQuirksResponse = + runCatching { assetReader.decode("device_bootloader_ota_quirks.json", json) } + .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } } + .getOrNull() ?: BootloaderOtaQuirksResponse() private fun applyBootloaderQuirk( hwModel: Int, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt index 7cc43e7878b..bcd13e71fdd 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt @@ -49,9 +49,9 @@ enum class SoftDeviceVariant { /** * Maps the wire value to a variant, returning `null` for anything unrecognized. * - * The asset carries a `String?` rather than this enum because the shared `Json` sets `coerceInputValues = true`, - * which would silently coerce a typo'd enum value to a default. An unrecognized string lands on `null` instead, - * and `null` refuses. + * The asset carries a `String?` rather than this enum because the shared `Json` sets `coerceInputValues = + * true`, which would silently coerce a typo'd enum value to a default. An unrecognized string lands on `null` + * instead, and `null` refuses. */ fun fromWire(value: String?): SoftDeviceVariant? = when (value) { "6.1.1" -> S140_6_1_1 diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index 148ece1a950..7b8296a5ec4 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -106,12 +106,13 @@ class UsbRepository( /** * Opens [driver]'s port, asserts DTR/RTS for [holdMillis], and closes it again. * - * Exists because some firmware waits for a host before it will run: the nRF52 factory-erase image blocks in - * `while (!Serial)` before formatting, and `Serial` only becomes truthy once DTR is asserted. Deliberately does not - * go through [createSerialConnection], which welds the assertion to a `SerialInputOutputManager` reader thread and a + * Exists because some firmware waits for a host before it will run: the nRF52 factory-erase image blocks in `while + * (!Serial)` before formatting, and `Serial` only becomes truthy once DTR is asserted. Deliberately does not go + * through [createSerialConnection], which welds the assertion to a `SerialInputOutputManager` reader thread and a * mandatory listener — there is no Meshtastic protocol on the other end here, just a blocked `setup()`. * - * @return true when the port was opened and DTR held; false if permission is missing or the port could not be opened. + * @return true when the port was opened and DTR held; false if permission is missing or the port could not be + * opened. */ suspend fun pokeDtr(driver: UsbSerialDriver, holdMillis: Long): Boolean = withContext(dispatchers.io) { val manager = usbManagerLazy.value ?: return@withContext false diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 9218ecf6378..d7a3a160bb2 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -147,10 +147,10 @@ actual fun rememberSaveFileLauncher( } @Composable -actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit { +actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - onTreeUriReceived(uri?.toKmpUri()) + onTreeUriSelect(uri?.toKmpUri()) } return remember(launcher) { { launcher.launch(null) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 3ec7f9d4c67..7721bc7b714 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -54,7 +54,7 @@ expect fun rememberSaveFileLauncher( * single-document URI does not. Needed where the app must inspect a volume before writing to it — for example reading a * UF2 bootloader's `INFO_UF2.TXT` to confirm which board it is and that the volume really is a bootloader drive. */ -@Composable expect fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit +@Composable expect fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit /** * Returns a suspend function that reads up to [maxChars] characters of text from a [CommonUri]. Returns `null` if the diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index fc3dc3762b2..c14d7399a11 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -46,7 +46,7 @@ actual fun rememberSaveFileLauncher( @Composable actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> } -@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit = {} +@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = {} @Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { _, _ -> null } diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 8a295a53949..984d7ef7161 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -91,13 +91,13 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT /** JVM — Opens a native dialog to pick a directory. */ @Composable -actual fun rememberOpenDocumentTreeLauncher(onTreeUriReceived: (CommonUri?) -> Unit): () -> Unit = { +actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = { // AWT FileDialog cannot select directories portably; JFileChooser can. val chooser = JFileChooser().apply { fileSelectionMode = JFileChooser.DIRECTORIES_ONLY } if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - onTreeUriReceived(CommonUri.parse(chooser.selectedFile.toURI().toString())) + onTreeUriSelect(CommonUri.parse(chooser.selectedFile.toURI().toString())) } else { - onTreeUriReceived(null) + onTreeUriSelect(null) } } diff --git a/feature/firmware/detekt-baseline.xml b/feature/firmware/detekt-baseline.xml index 71ba09da404..550a63d6a09 100644 --- a/feature/firmware/detekt-baseline.xml +++ b/feature/firmware/detekt-baseline.xml @@ -12,7 +12,7 @@ MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun CheckingState MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun ErrorState MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun VerifyingState - MultipleEmitters:FirmwareUpdateScreen.kt:@Composable private fun AwaitingFileSaveState + MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun AwaitingFileSaveState MultipleEmitters:FirmwareUpdateScreen.kt:@Composable private fun VerificationFailedState PreviewPublic:FirmwarePreviews.kt:@PreviewLightDark @Composable fun CheckingStatePreview PreviewPublic:FirmwarePreviews.kt:@PreviewLightDark @Composable fun DisclaimerDialogPreview diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index d035565f03c..bef607d92f2 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -392,7 +392,8 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien withContext(ioDispatcher) { runCatching { val tree = treeUri.toAndroidUri() - val parent = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + val parent = + DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) DocumentsContract.createDocument(context.contentResolver, parent, mimeType, fileName)?.let { CommonUri.parse(it.toString()) } diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt index ad5095bc0e3..c260a657dbc 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt @@ -40,6 +40,7 @@ class AndroidFirmwareUsbManager(private val context: Context, private val usbRep override suspend fun serialPortKeys(): Set = usbRepository.serialDevices.value.keys + @Suppress("ReturnCount") // each failed precondition must abort without poking an arbitrary port override suspend fun unblockCdcPort(excluding: Set, waitMillis: Long, holdMillis: Long): Boolean { // The erase image enumerates a moment after the write, so poll rather than sampling once. val driver = @@ -57,7 +58,8 @@ class AndroidFirmwareUsbManager(private val context: Context, private val usbRep // device_filter.xml lists no bootloader-mode ids (and none at all for Seeed's 0x2886), so no implicit grant // exists here — an explicit request is the only route. - if (!usbRepository.hasPermission(driver.device) && + if ( + !usbRepository.hasPermission(driver.device) && usbRepository.requestPermission(driver.device).first() != true ) { Logger.w { "USB permission denied; cannot unblock the erase image" } @@ -66,6 +68,7 @@ class AndroidFirmwareUsbManager(private val context: Context, private val usbRep return usbRepository.pokeDtr(driver, holdMillis) } + /** Observe when a USB device is detached. */ override fun deviceDetachFlow(): Flow = callbackFlow { val receiver = diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 052bd9e0355..c850dec8385 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -29,6 +29,9 @@ private val KNOWN_ARCHS = setOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master" +/** Radix for the hex flash addresses in maintenance-image diagnostics. */ +private const val HEX_RADIX = 16 + /** OTA partition role in .mt.json manifests — the main application firmware. */ private const val OTA_PART_NAME = "app0" @@ -88,7 +91,11 @@ open class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { * * @return The verified [FirmwareArtifact], or `null` when the download failed or verification did not pass. */ - internal open suspend fun retrieveMaintenanceUf2(asset: MaintenanceUf2, onProgress: (Float) -> Unit): FirmwareArtifact? { + @Suppress("ReturnCount") // guard clauses: every failure path must abort before a destructive write + internal open suspend fun retrieveMaintenanceUf2( + asset: MaintenanceUf2, + onProgress: (Float) -> Unit, + ): FirmwareArtifact? { val artifact = try { fileHandler.downloadFile(asset.url, asset.fileName, onProgress) @@ -100,7 +107,9 @@ open class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { val bytes = fileHandler.readBytes(artifact) val actualDigest = FirmwareHashUtil.bytesToHex(FirmwareHashUtil.calculateSha256Bytes(bytes)) if (!actualDigest.equals(asset.sha256, ignoreCase = true)) { - Logger.e { "Maintenance image ${asset.fileName} digest mismatch (expected ${asset.sha256}, got $actualDigest)" } + Logger.e { + "Maintenance image ${asset.fileName} digest mismatch (expected ${asset.sha256}, got $actualDigest)" + } fileHandler.deleteFile(artifact) return null } @@ -110,10 +119,11 @@ open class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) { val actualAddress = uf2FirstTargetAddress(bytes) if (actualAddress != expectedAddress) { // The digest proved which file arrived; this proves the pinned row itself isn't mismatched — a swapped - // URL/digest pair is the one authoring error a digest cannot catch, and the one that erases a SoftDevice. + // URL/digest pair is the one authoring error a digest cannot catch, and the one that erases a + // SoftDevice. Logger.e { - "Maintenance image ${asset.fileName} targets ${actualAddress?.toString(16)}, " + - "expected ${expectedAddress.toString(16)} — refusing to write" + "Maintenance image ${asset.fileName} targets ${actualAddress?.toString(HEX_RADIX)}, " + + "expected ${expectedAddress.toString(HEX_RADIX)} — refusing to write" } fileHandler.deleteFile(artifact) return null 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 71443cc0b9b..4cc30c3cb71 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 @@ -192,7 +192,9 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView // Maintenance passes pick the whole volume, not a filename, so the app can read INFO_UF2.TXT and confirm it really // is the device's update drive before writing anything to it. - val volumePickerLauncher = rememberOpenDocumentTreeLauncher { uri -> uri?.let { viewModel.writeMaintenancePass(it) } } + val volumePickerLauncher = rememberOpenDocumentTreeLauncher { uri -> + uri?.let { viewModel.writeMaintenancePass(it) } + } val actions = remember(viewModel, onNavigateUp) { @@ -420,7 +422,11 @@ private fun FirmwareUpdateContent( SuccessState(onDone = actions.onDone, wasLowSpeedTransfer = state.wasLowSpeedTransfer) is FirmwareUpdateState.AwaitingFileSave -> - AwaitingFileSaveState(state = state, onSaveFile = actions.onSaveFile, onPickVolume = actions.onPickVolume) + AwaitingFileSaveState( + state = state, + onSaveFile = actions.onSaveFile, + onPickVolume = actions.onPickVolume, + ) } } } 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 ed6b40242b9..c868646c211 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 @@ -93,14 +93,15 @@ sealed interface FirmwareUpdateState { /** * Waiting for the user to point the app at the device's UF2 drive (USB flow). * - * @property uf2Artifact The image to write, when it is already downloaded and verified. `null` on a maintenance pass: - * erase and bootloader images are chosen from what the mounted volume reports about itself, so the image cannot be - * known until the volume has been picked. + * @property uf2Artifact The image to write, when it is already downloaded and verified. `null` on a maintenance + * pass: erase and bootloader images are chosen from what the mounted volume reports about itself, so the image + * cannot be known until the volume has been picked. * @property fileName Display name of [uf2Artifact], or `null` for the same reason. * @property step Which leg of a multi-pass sequence this is. Also the recomposition key for the instruction dialog, * so each pass re-shows its own instructions. * @property retryMessage Set when a previous attempt at *this* pass failed. Once a destructive image has been - * written the flow never abandons the user on an error screen — it re-publishes the same pass with an explanation. + * written the flow never abandons the user on an error screen — it re-publishes the same pass with an + * explanation. */ data class AwaitingFileSave( val uf2Artifact: FirmwareArtifact?, 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 2e2419fe2c6..c7659380305 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 @@ -495,6 +495,7 @@ class FirmwareUpdateViewModel( fun startBootloaderUpgrade() = startUsbMaintenance(UsbMaintenanceRequest.BootloaderUpgrade) + @Suppress("ReturnCount") // preconditions; each missing one must abort before the device is rebooted private fun startUsbMaintenance(request: UsbMaintenanceRequest) { val currentState = _state.value as? FirmwareUpdateState.Ready ?: return val release = currentState.release ?: return @@ -543,10 +544,11 @@ class FirmwareUpdateViewModel( /** * Resumes a maintenance sequence once the user has pointed at the device's UF2 volume. * - * Distinct from [saveDfuFile] because the URI kinds differ: this takes a *tree* URI, which grants the sibling access - * needed to read `INFO_UF2.TXT` and vet the volume before writing. Each pass re-picks, because the device + * Distinct from [saveDfuFile] because the URI kinds differ: this takes a *tree* URI, which grants the sibling + * access needed to read `INFO_UF2.TXT` and vet the volume before writing. Each pass re-picks, because the device * re-enumerates between passes and the previous grant no longer refers to the mounted volume. */ + @Suppress("ReturnCount") // preconditions guarding a destructive write fun writeMaintenancePass(treeUri: CommonUri) { val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return val pass = pendingUsbPasses.firstOrNull() ?: return @@ -555,10 +557,10 @@ class FirmwareUpdateViewModel( viewModelScope.launch { try { - // Capture the ports present before the write so the erase image's port can be told from pre-existing ones. + // Capture the ports present before the write so the erase image's port can be told from pre-existing + // ones. val portsBefore = usbManager.serialPortKeys() - val result = - usbPassWriter(portsBefore).write(pass, treeUri, hardware) { _state.value = it } + val result = usbPassWriter(portsBefore).write(pass, treeUri, hardware) { _state.value = it } handlePassResult(pass, result) } catch (e: CancellationException) { throw e @@ -577,7 +579,8 @@ class FirmwareUpdateViewModel( UsbPassResult.ImageDownloadFailed -> reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_retrieval_failed)) - UsbPassResult.CopyFailed -> reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_copy_failed)) + UsbPassResult.CopyFailed -> + reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_copy_failed)) UsbPassResult.WriteDidNotLand -> reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_wrong_destination)) @@ -601,19 +604,24 @@ class FirmwareUpdateViewModel( /** * Re-offers the same pass with an explanation, or fails outright when nothing destructive has happened yet. * - * Once an erase or bootloader image has been written the device has no application, so dropping the user on an error - * screen is the worst available outcome — the flow keeps offering the pass until it succeeds or they leave + * Once an erase or bootloader image has been written the device has no application, so dropping the user on an + * error screen is the worst available outcome — the flow keeps offering the pass until it succeeds or they leave * deliberately. */ private fun reofferOrFail(pass: UsbFileSavePass, message: UiText) { _state.value = - if (destructiveWriteDone) pass.toAwaitingFileSave(retryMessage = message) - else FirmwareUpdateState.Error(message) + if (destructiveWriteDone) { + pass.toAwaitingFileSave(retryMessage = message) + } else { + FirmwareUpdateState.Error(message) + } } private fun usbPassWriter(portsBefore: Set) = UsbPassWriter( fileHandler = fileHandler, - retrieveMaintenanceUf2 = { asset, onProgress -> firmwareRetriever.retrieveMaintenanceUf2(asset, onProgress) }, + retrieveMaintenanceUf2 = { asset, onProgress -> + firmwareRetriever.retrieveMaintenanceUf2(asset, onProgress) + }, awaitDeviceDetach = { timeout -> withTimeoutOrNull(timeout) { usbManager.deviceDetachFlow().first() } != null }, @@ -638,8 +646,7 @@ class FirmwareUpdateViewModel( UsbMaintenanceRefusal.SoftDeviceConflict -> UiText.Resource(Res.string.firmware_maintenance_softdevice_conflict) - UsbMaintenanceRefusal.UnknownBoardId -> - UiText.Resource(Res.string.firmware_maintenance_unknown_board) + UsbMaintenanceRefusal.UnknownBoardId -> UiText.Resource(Res.string.firmware_maintenance_unknown_board) } fun saveDfuFile(uri: CommonUri) { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt index 380fd61a9fd..7df740dc3f0 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt @@ -22,11 +22,11 @@ interface FirmwareUsbManager { fun deviceDetachFlow(): Flow /** - * Records which serial ports are present, so a port that appears *after* a UF2 write can be told apart from one that - * was already there. + * Records which serial ports are present, so a port that appears *after* a UF2 write can be told apart from one + * that was already there. * - * Snapshot-then-diff rather than matching a VID/PID table: bootloader-mode ids are per-board and collide (four OTAFIX - * boards share `239A/0029`), and identifying by serial number needs a permission grant we may not hold yet. + * Snapshot-then-diff rather than matching a VID/PID table: bootloader-mode ids are per-board and collide (four + * OTAFIX boards share `239A/0029`), and identifying by serial number needs a permission grant we may not hold yet. */ suspend fun serialPortKeys(): Set 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 4fd7c77856c..fb0620b76e9 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 @@ -22,8 +22,8 @@ import org.meshtastic.core.model.SoftDeviceVariant /** * A pinned, content-verified UF2 image used by a maintenance flow (factory erase, bootloader upgrade). * - * Unlike release firmware — which `FirmwareRetriever` resolves against a versioned release folder — these images have no - * versioned upstream. `meshtastic/nrf52_factory_erase` has cut no GitHub releases, so the flasher serves them from a + * Unlike release firmware — which `FirmwareRetriever` resolves against a versioned release folder — these images have + * no versioned upstream. `meshtastic/nrf52_factory_erase` has cut no GitHub releases, so the flasher serves them from a * mutable `public/uf2/` path. Pinning the URL to a commit and verifying [sha256] before any write is what turns that * mutable path back into an immutable one. * @@ -68,25 +68,28 @@ internal const val APP_START_S140_6_1_1 = 0x26000L /** S140 7.3.0 application start — the address `nrf_erase_sd7_3.uf2` is linked for. */ internal const val APP_START_S140_7_3_0 = 0x27000L -private val ERASE_S140_6_1_1 = MaintenanceUf2( - url = "$ERASE_UF2_BASE/nrf_erase2.uf2", - fileName = "nrf_erase2.uf2", - sha256 = "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe", - expectedFirstTargetAddress = APP_START_S140_6_1_1, -) - -private val ERASE_S140_7_3_0 = MaintenanceUf2( - url = "$ERASE_UF2_BASE/nrf_erase_sd7_3.uf2", - fileName = "nrf_erase_sd7_3.uf2", - sha256 = "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f", - expectedFirstTargetAddress = APP_START_S140_7_3_0, -) - -private val PICO_ERASE = MaintenanceUf2( - url = "$ERASE_UF2_BASE/pico_erase.uf2", - fileName = "pico_erase.uf2", - sha256 = "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853", -) +private val ERASE_S140_6_1_1 = + MaintenanceUf2( + url = "$ERASE_UF2_BASE/nrf_erase2.uf2", + fileName = "nrf_erase2.uf2", + sha256 = "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe", + expectedFirstTargetAddress = APP_START_S140_6_1_1, + ) + +private val ERASE_S140_7_3_0 = + MaintenanceUf2( + url = "$ERASE_UF2_BASE/nrf_erase_sd7_3.uf2", + fileName = "nrf_erase_sd7_3.uf2", + sha256 = "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f", + expectedFirstTargetAddress = APP_START_S140_7_3_0, + ) + +private val PICO_ERASE = + MaintenanceUf2( + url = "$ERASE_UF2_BASE/pico_erase.uf2", + fileName = "pico_erase.uf2", + sha256 = "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853", + ) private fun otafixAsset(board: String, sha256: String): MaintenanceUf2 { val name = "update-${board}_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2" @@ -107,78 +110,76 @@ private fun otafixAsset(board: String, sha256: String): MaintenanceUf2 { * This is also the only way to resolve the XIAO nRF52840 BLE / BLE Sense split that OTAFIX's own README calls out: the * two differ only by Board-ID, and installing the wrong one over UF2 is exactly what that note warns against. */ -private val OTAFIX_BY_BOARD_ID: Map = mapOf( - "HT-n5262" to - otafixAsset( - board = "heltec_t114", - sha256 = "d7a51ef7e41e7ba3b5906c162e8926ca0bc88a5010436a3589991d5a99741220", - ), - "MinewSemi-MX25LE01" to - otafixAsset( - board = "minewsemi_mx25le01", - sha256 = "3d2039b3e22e0b350b49a8456e30709d2f8be440a239d2ed864091b1c4abc3ae", - ), - "TRACKER L1" to - otafixAsset( - board = "wio_tracker_l1", - sha256 = "efe1fc16f64f04deb26d171b9a3f6d81b32767ff01c40fe894924b8c682a2964", - ), - "WisBlock-RAK4631-Board" to - otafixAsset( - board = "wiscore_rak4631_board", - sha256 = "3509c8b01296bc6473acf5a9422f9aec857a5bbb47e80000f9b98e15b046e46a", - ), - "WisMesh-Tag" to - otafixAsset( - board = "wismesh_tag", - sha256 = "e1701badf22d9684d953cb5274d5d9241c3bf776c2524b935ef21a607c0bca4a", - ), - "nRF52840-SeeedSenseCAPSolarP1-v1" to - otafixAsset( - board = "sensecap_solar_p1", - sha256 = "efe28af706a4a3e5604390d3bf5bcbeb7470aadc957e191c7e26e8fadc2ed4e5", - ), - "nRF52840-SeeedXiao-v1" to - otafixAsset( - board = "xiao_nrf52840_ble", - sha256 = "49364762fb9992334fe55b3bea7f2d30e14dce4433de6c31e90036f6ec95d2a4", - ), - "nRF52840-SeeedXiaoSense-v1" to - otafixAsset( - board = "xiao_nrf52840_ble_sense", - sha256 = "fc492fc2e30f2f75789217c0ead68ad64917de0844e6011269755fddfe532c36", - ), - "nRF52840-T1000-E-v1" to - otafixAsset( - board = "t1000_e", - sha256 = "c1dd30cce0f250eb7ad21e8c065cef6692b53e7efcba0578371bedcfd2493cc9", - ), - "nRF52840-TEcho-v1" to - otafixAsset( - board = "lilygo_techo", - sha256 = "244c3ea9a783dbcfd9fabfa95d5f8bae3a52246bbe41c5414b2b6657683025c9", - ), - "nRF52840-ThinkNode-M3-v1" to - otafixAsset( - board = "thinknode_m3", - sha256 = "073a6b6acf1bb0ca9ea4e8f7ca3c8df1cf3992aced06650b85c00aba5fa3ff13", - ), - "nRF52840-ThinkNodeM1-v1" to - otafixAsset( - board = "thinknode_m1", - sha256 = "315d36a189b30bbc09c7846031483caea669b1d0c7237da9221ab2794757498f", - ), - "nRF52840-ThinkNodeM6-v1" to - otafixAsset( - board = "thinknode_m6", - sha256 = "a7e977a8af02946559a8c703f1d2f27e22fd1cbedf61a9bc7af3472eb86fe99f", - ), - "nRF52840-promicro" to - otafixAsset( - board = "promicro_nrf52840", - sha256 = "5ceb9ad4a8092f1319fc5371649eeae55b47c5ff2acd647972558017ba51dccb", - ), -) +private val OTAFIX_BY_BOARD_ID: Map = + mapOf( + "HT-n5262" to + otafixAsset( + board = "heltec_t114", + sha256 = "d7a51ef7e41e7ba3b5906c162e8926ca0bc88a5010436a3589991d5a99741220", + ), + "MinewSemi-MX25LE01" to + otafixAsset( + board = "minewsemi_mx25le01", + sha256 = "3d2039b3e22e0b350b49a8456e30709d2f8be440a239d2ed864091b1c4abc3ae", + ), + "TRACKER L1" to + otafixAsset( + board = "wio_tracker_l1", + sha256 = "efe1fc16f64f04deb26d171b9a3f6d81b32767ff01c40fe894924b8c682a2964", + ), + "WisBlock-RAK4631-Board" to + otafixAsset( + board = "wiscore_rak4631_board", + sha256 = "3509c8b01296bc6473acf5a9422f9aec857a5bbb47e80000f9b98e15b046e46a", + ), + "WisMesh-Tag" to + otafixAsset( + board = "wismesh_tag", + sha256 = "e1701badf22d9684d953cb5274d5d9241c3bf776c2524b935ef21a607c0bca4a", + ), + "nRF52840-SeeedSenseCAPSolarP1-v1" to + otafixAsset( + board = "sensecap_solar_p1", + sha256 = "efe28af706a4a3e5604390d3bf5bcbeb7470aadc957e191c7e26e8fadc2ed4e5", + ), + "nRF52840-SeeedXiao-v1" to + otafixAsset( + board = "xiao_nrf52840_ble", + sha256 = "49364762fb9992334fe55b3bea7f2d30e14dce4433de6c31e90036f6ec95d2a4", + ), + "nRF52840-SeeedXiaoSense-v1" to + otafixAsset( + board = "xiao_nrf52840_ble_sense", + sha256 = "fc492fc2e30f2f75789217c0ead68ad64917de0844e6011269755fddfe532c36", + ), + "nRF52840-T1000-E-v1" to + otafixAsset(board = "t1000_e", sha256 = "c1dd30cce0f250eb7ad21e8c065cef6692b53e7efcba0578371bedcfd2493cc9"), + "nRF52840-TEcho-v1" to + otafixAsset( + board = "lilygo_techo", + sha256 = "244c3ea9a783dbcfd9fabfa95d5f8bae3a52246bbe41c5414b2b6657683025c9", + ), + "nRF52840-ThinkNode-M3-v1" to + otafixAsset( + board = "thinknode_m3", + sha256 = "073a6b6acf1bb0ca9ea4e8f7ca3c8df1cf3992aced06650b85c00aba5fa3ff13", + ), + "nRF52840-ThinkNodeM1-v1" to + otafixAsset( + board = "thinknode_m1", + sha256 = "315d36a189b30bbc09c7846031483caea669b1d0c7237da9221ab2794757498f", + ), + "nRF52840-ThinkNodeM6-v1" to + otafixAsset( + board = "thinknode_m6", + sha256 = "a7e977a8af02946559a8c703f1d2f27e22fd1cbedf61a9bc7af3472eb86fe99f", + ), + "nRF52840-promicro" to + otafixAsset( + board = "promicro_nrf52840", + sha256 = "5ceb9ad4a8092f1319fc5371649eeae55b47c5ff2acd647972558017ba51dccb", + ), + ) /** * Meshtastic `platformioTarget`s whose products OTAFIX lists as supported. @@ -191,28 +192,29 @@ private val OTAFIX_BY_BOARD_ID: Map = mapOf( * supported one — WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against `wiscore_rak4631`, and T-Echo * Plus/Lite against `t-echo`, but OTAFIX ships no bootloader for them. */ -private val OTAFIX_SUPPORTED_TARGETS: Set = setOf( - "rak4631", - "rak_wismeshtag", - "t-echo", - "heltec-mesh-node-t114", - "nrf52_promicro_diy_tcxo", - "thinknode_m1", - "thinknode_m3", - "thinknode_m6", - "tracker-t1000-e", - "seeed_wio_tracker_L1", - "seeed_wio_tracker_L1_eink", - "seeed_solar_node", - "seeed_xiao_nrf52840_kit", -) +private val OTAFIX_SUPPORTED_TARGETS: Set = + setOf( + "rak4631", + "rak_wismeshtag", + "t-echo", + "heltec-mesh-node-t114", + "nrf52_promicro_diy_tcxo", + "thinknode_m1", + "thinknode_m3", + "thinknode_m6", + "tracker-t1000-e", + "seeed_wio_tracker_L1", + "seeed_wio_tracker_L1_eink", + "seeed_solar_node", + "seeed_xiao_nrf52840_kit", + ) /** * The factory-erase image for [hardware], or `null` when none can be resolved safely. * * nRF52840 requires a resolved [DeviceHardware.softDeviceVariant]: the two images are linked for different application - * start addresses, and the UF2 bootloader's write guard begins at `MBR_SIZE`, so the wrong one erases a SoftDevice page. - * There is deliberately no default branch. + * start addresses, and the UF2 bootloader's write guard begins at `MBR_SIZE`, so the wrong one erases a SoftDevice + * page. There is deliberately no default branch. */ internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when { hardware.isRp2040Arc -> PICO_ERASE @@ -227,7 +229,9 @@ internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when { else -> null } -/** True when OTAFIX lists a bootloader for [platformioTarget]'s product. UX gate only — see [OTAFIX_SUPPORTED_TARGETS]. */ +/** + * True when OTAFIX lists a bootloader for [platformioTarget]'s product. UX gate only — see [OTAFIX_SUPPORTED_TARGETS]. + */ internal fun otafixSupportsTarget(platformioTarget: String): Boolean = platformioTarget in OTAFIX_SUPPORTED_TARGETS /** @@ -265,24 +269,26 @@ internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text * OTAFIX, and verified on a stock Seeed bootloader (`SoftDevice: S140 7.3.0`). * * This is the **authoritative** answer to the question the bundled map only estimates: not what the firmware was built - * against, but which SoftDevice is actually in flash. Returns `null` when the line is absent (very old bootloader), when - * no SoftDevice is installed, or when the id/version is not one we ship an erase image for. + * against, but which SoftDevice is actually in flash. Returns `null` when the line is absent (very old bootloader), + * when no SoftDevice is installed, or when the id/version is not one we ship an erase image for. */ +@Suppress("ReturnCount") // guard clauses; an unparseable line must yield null rather than a guess internal fun parseUf2SoftDevice(infoUf2Text: String): SoftDeviceVariant? { val value = infoUf2Text .lineSequence() .firstOrNull { it.trimStart().startsWith(UF2_SOFTDEVICE_PREFIX, ignoreCase = true) } ?.substringAfter(':') - ?.trim() - ?: return null + ?.trim() ?: return null val parts = value.split(' ').filter { it.isNotBlank() } if (parts.size < 2 || !parts[0].equals(SUPPORTED_SOFTDEVICE_ID, ignoreCase = true)) return null return SoftDeviceVariant.fromWire(parts[1]) } -/** Which erase image a given variant needs. Total over the enum, so a new variant cannot silently reuse an old image. */ +/** + * Which erase image a given variant needs. Total over the enum, so a new variant cannot silently reuse an old image. + */ internal fun eraseUf2ForVariant(variant: SoftDeviceVariant): MaintenanceUf2 = when (variant) { SoftDeviceVariant.S140_6_1_1 -> ERASE_S140_6_1_1 SoftDeviceVariant.S140_7_3_0 -> ERASE_S140_7_3_0 @@ -306,9 +312,9 @@ internal sealed interface EraseImageResolution { /** * Picks the nRF erase image, preferring what the device reports over what the bundled map predicted. * - * The map ([DeviceHardware.softDeviceVariant]) is a pre-flight hint — it decides whether the action is offered before any - * drive is mounted. Once the drive is readable its own report wins, because it comes from the MBR rather than from a - * hand-authored table. A disagreement refuses rather than picking a side. + * The map ([DeviceHardware.softDeviceVariant]) is a pre-flight hint — it decides whether the action is offered before + * any drive is mounted. Once the drive is readable its own report wins, because it comes from the MBR rather than from + * a hand-authored table. A disagreement refuses rather than picking a side. */ internal fun resolveNrfEraseImage( mapped: SoftDeviceVariant?, @@ -317,7 +323,8 @@ internal fun resolveNrfEraseImage( reportedFromDrive != null && mapped != null && reportedFromDrive != mapped -> EraseImageResolution.Conflict(reported = reportedFromDrive, mapped = mapped) - reportedFromDrive != null -> EraseImageResolution.Resolved(eraseUf2ForVariant(reportedFromDrive), reportedFromDrive) + reportedFromDrive != null -> + EraseImageResolution.Resolved(eraseUf2ForVariant(reportedFromDrive), reportedFromDrive) // No SoftDevice line: a bootloader older than the uf2_init that emits it. Fall back to the bundled hint. mapped != null -> EraseImageResolution.Resolved(eraseUf2ForVariant(mapped), mapped) @@ -343,11 +350,19 @@ internal const val UF2_TARGET_ADDR_OFFSET = 12 private const val UF2_MAGIC_START0 = 0x0A324655 +/** Bytes in a little-endian 32-bit field, and the mask/shift used to reassemble one. */ +private const val UINT32_BYTES = 4 + +private const val BITS_PER_BYTE = 8 + +private const val BYTE_MASK = 0xFFL + /** * Reads the target flash address of the first UF2 block in [bytes], or `null` when the payload is not a UF2 image. * * Used to cross-check a pinned erase image against the resolved SoftDevice variant before it is written. */ +@Suppress("ReturnCount") // guard clauses over a binary header internal fun uf2FirstTargetAddress(bytes: ByteArray): Long? { if (bytes.size < UF2_BLOCK_BYTES) return null if (readLittleEndianUInt32(bytes, 0) != UF2_MAGIC_START0.toLong()) return null @@ -356,8 +371,8 @@ internal fun uf2FirstTargetAddress(bytes: ByteArray): Long? { private fun readLittleEndianUInt32(bytes: ByteArray, offset: Int): Long { var value = 0L - for (i in 3 downTo 0) { - value = (value shl 8) or (bytes[offset + i].toLong() and 0xFF) + for (i in UINT32_BYTES - 1 downTo 0) { + value = (value shl BITS_PER_BYTE) or (bytes[offset + i].toLong() and BYTE_MASK) } return value } 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 10e6bfb53b9..e382f57f844 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 @@ -28,7 +28,9 @@ import org.meshtastic.core.model.SoftDeviceVariant * consecutive identical steps would need a pass index added here. */ enum class UsbFileSaveStep { - /** The release firmware image — the only pass in a plain update, and the last pass of every maintenance sequence. */ + /** + * The release firmware image — the only pass in a plain update, and the last pass of every maintenance sequence. + */ Firmware, /** The factory-erase image, which wipes the internal filesystem and re-enters UF2 DFU. */ @@ -36,6 +38,7 @@ enum class UsbFileSaveStep { /** An OTAFIX bootloader self-update image. */ BootloaderUpgrade, + ; /** @@ -56,8 +59,8 @@ enum class UsbMaintenanceRequest { @Suppress("UndocumentedPublicProperty") enum class UsbMaintenanceRefusal { /** - * The device's SoftDevice variant could not be resolved, so no erase image can be chosen safely. Covers an absent or - * malformed metadata asset, an unmapped hardware model, and a device reporting a target we have no mapping for. + * The device's SoftDevice variant could not be resolved, so no erase image can be chosen safely. Covers an absent + * or malformed metadata asset, an unmapped hardware model, and a device reporting a target we have no mapping for. */ UnknownSoftDevice, @@ -77,8 +80,8 @@ enum class UsbMaintenanceRefusal { NotABootloaderVolume, /** - * The volume's reported SoftDevice contradicts the bundled map. One of the two is wrong and we cannot tell which, so - * refuse — and treat the map row as suspect. + * The volume's reported SoftDevice contradicts the bundled map. One of the two is wrong and we cannot tell which, + * so refuse — and treat the map row as suspect. */ SoftDeviceConflict, @@ -160,18 +163,15 @@ internal sealed interface VolumeInspection { * drive, where a removability heuristic only makes "somewhere on internal storage" less likely. This is what stops the * common mis-tap (saving into Downloads) from being indistinguishable from a successful flash. */ -internal suspend fun inspectMaintenanceVolume( - treeUri: CommonUri, - fileHandler: FirmwareFileHandler, -): VolumeInspection { +@Suppress("ReturnCount") // two independent vetting gates, each of which must reject immediately +internal suspend fun inspectMaintenanceVolume(treeUri: CommonUri, fileHandler: FirmwareFileHandler): VolumeInspection { if (!fileHandler.isRemovableDestination(treeUri)) { return VolumeInspection.Rejected(UsbMaintenanceRefusal.DestinationNotRemovable) } val info = fileHandler.readSiblingText(treeUri, INFO_UF2_FILE_NAME) ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume) - val boardId = - parseUf2BoardId(info) ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume) + val boardId = parseUf2BoardId(info) ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume) return VolumeInspection.Accepted(MaintenanceVolume(boardId = boardId, softDevice = parseUf2SoftDevice(info))) } @@ -199,6 +199,7 @@ internal fun chooseMaintenanceImage( if (hardware.isNrf52Arc) { when (val resolution = resolveNrfEraseImage(hardware.softDeviceVariant, volume.softDevice)) { is EraseImageResolution.Resolved -> MaintenanceImageChoice.Resolved(resolution.asset) + is EraseImageResolution.Conflict -> MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.SoftDeviceConflict) @@ -207,13 +208,11 @@ internal fun chooseMaintenanceImage( } } else { // RP2040: pico_erase is board-agnostic and there is no SoftDevice to reconcile. - eraseUf2For(hardware) - ?.let { MaintenanceImageChoice.Resolved(it) } + eraseUf2For(hardware)?.let { MaintenanceImageChoice.Resolved(it) } ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnsupportedArchitecture) } UsbMaintenanceRequest.BootloaderUpgrade -> - otafixUf2ForBoardId(volume.boardId) - ?.let { MaintenanceImageChoice.Resolved(it) } + otafixUf2ForBoardId(volume.boardId)?.let { MaintenanceImageChoice.Resolved(it) } ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnknownBoardId) } 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 d12e544ab51..226b7461033 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 @@ -59,11 +59,8 @@ internal sealed interface UsbFileSavePass { ) : UsbFileSavePass /** Already downloaded and verified. */ - data class Prepared( - override val step: UsbFileSaveStep, - val artifact: FirmwareArtifact, - val fileName: String, - ) : UsbFileSavePass + data class Prepared(override val step: UsbFileSaveStep, val artifact: FirmwareArtifact, val fileName: String) : + UsbFileSavePass } /** @@ -72,9 +69,10 @@ internal sealed interface UsbFileSavePass { * * Ordering is deliberate. The **application** image is downloaded and verified *before* `rebootToDfu`, because it is * what restores the device after a destructive write — we never erase without already holding the firmware to put back. - * The **maintenance** image cannot be fetched this early: erase and bootloader images are selected from the Board-ID and - * SoftDevice the mounted volume reports, and no volume exists until the device has rebooted. It is fetched later, after - * the volume is vetted but still before anything is written, so a failure there costs a message rather than a device. + * The **maintenance** image cannot be fetched this early: erase and bootloader images are selected from the Board-ID + * and SoftDevice the mounted volume reports, and no volume exists until the device has rebooted. It is fetched later, + * after the volume is vetted but still before anything is written, so a failure there costs a message rather than a + * device. * * @return The ordered passes, or an empty list when preparation failed (state has already been set to an error). */ @@ -212,33 +210,9 @@ internal class UsbPassWriter( } val artifact = - when (pass) { - is UsbFileSavePass.Prepared -> pass.artifact - - is UsbFileSavePass.FromVolume -> { - when (val choice = chooseMaintenanceImage(pass.request, hardware, volume)) { - is MaintenanceImageChoice.Refused -> return UsbPassResult.Refused(choice.reason) - is MaintenanceImageChoice.Resolved -> { - val downloadingMsg = - getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs() - updateState( - FirmwareUpdateState.Downloading( - ProgressState(message = UiText.DynamicString(downloadingMsg)), - ), - ) - retrieveMaintenanceUf2(choice.asset) { progress -> - updateState( - FirmwareUpdateState.Downloading( - ProgressState( - message = UiText.DynamicString(downloadingMsg), - progress = progress, - ), - ), - ) - } ?: return UsbPassResult.ImageDownloadFailed - } - } - } + when (val resolved = resolveImage(pass, hardware, volume, updateState)) { + is ImageResolution.Failed -> return resolved.result + is ImageResolution.Ready -> resolved.artifact } val fileName = artifact.fileName ?: return UsbPassResult.CopyFailed @@ -280,6 +254,49 @@ internal class UsbPassWriter( return UsbPassResult.Written } + + /** Either the image to write, or the result to return instead. */ + private sealed interface ImageResolution { + data class Ready(val artifact: FirmwareArtifact) : ImageResolution + + data class Failed(val result: UsbPassResult) : ImageResolution + } + + /** + * Produces the image for [pass]: already downloaded for the application pass, or chosen from what [volume] reports + * and fetched now for a maintenance pass. + */ + private suspend fun resolveImage( + pass: UsbFileSavePass, + hardware: DeviceHardware, + volume: MaintenanceVolume, + updateState: (FirmwareUpdateState) -> Unit, + ): ImageResolution = when (pass) { + is UsbFileSavePass.Prepared -> ImageResolution.Ready(pass.artifact) + + is UsbFileSavePass.FromVolume -> + when (val choice = chooseMaintenanceImage(pass.request, hardware, volume)) { + is MaintenanceImageChoice.Refused -> ImageResolution.Failed(UsbPassResult.Refused(choice.reason)) + + is MaintenanceImageChoice.Resolved -> { + val downloadingMsg = + getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs() + val artifact = + retrieveMaintenanceUf2(choice.asset) { progress -> + updateState( + FirmwareUpdateState.Downloading( + ProgressState( + message = UiText.DynamicString(downloadingMsg), + progress = progress, + ), + ), + ) + } + artifact?.let { ImageResolution.Ready(it) } + ?: ImageResolution.Failed(UsbPassResult.ImageDownloadFailed) + } + } + } } /** UF2 images are opaque binaries as far as the document provider is concerned. */ 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 619dc061886..199ce2523bb 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 @@ -42,8 +42,7 @@ abstract class CommonMaintenanceVolumeTest { softDeviceVariant = variant, ) - private fun rp2040() = - DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040") + private fun rp2040() = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040") private val rakInfo = "UF2 Bootloader 0.4.3\r\nModel: WisBlock RAK4631 Board\r\n" + @@ -52,10 +51,8 @@ abstract class CommonMaintenanceVolumeTest { /** RP2040 BOOTSEL volumes expose an INFO_UF2.TXT too, with no SoftDevice line. */ private val picoInfo = "UF2 Bootloader v3.0\r\nModel: Raspberry Pi RP2\r\nBoard-ID: RPI-RP2\r\n" - private class FakeVolume( - private val removable: Boolean = true, - private val info: String? = null, - ) : NoopFirmwareFileHandler() { + private class FakeVolume(private val removable: Boolean = true, private val info: String? = null) : + NoopFirmwareFileHandler() { var readCount = 0 private set @@ -80,7 +77,8 @@ abstract class CommonMaintenanceVolumeTest { @Test fun `an rp2040 bootsel volume is accepted with no softdevice`() = runTest { - val accepted = assertIs(inspectMaintenanceVolume(treeUri, FakeVolume(info = picoInfo))) + val accepted = + assertIs(inspectMaintenanceVolume(treeUri, FakeVolume(info = picoInfo))) assertEquals("RPI-RP2", accepted.volume.boardId) assertEquals(null, accepted.volume.softDevice, "RP2040 has no SoftDevice to report") @@ -181,7 +179,7 @@ abstract class CommonMaintenanceVolumeTest { } @Test - fun `bootloader upgrade resolves from the volume's board id, not the build target`() { + fun `bootloader upgrade resolves from the volume board id rather than the build target`() { // Hardware reports itself as a T-Echo while the catalog target says rak4631: the volume wins, because it is the // thing physically in front of us. val choice = 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 c5889483fa7..dc015329c6f 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 @@ -155,7 +155,14 @@ class UsbMaintenanceGateTest { fun `otafix is not offered for products that merely share a supported build target`() { // WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against wiscore_rak4631, and T-Echo Plus/Lite // against t-echo, but OTAFIX ships no bootloader for those products. - listOf("rak2560", "rak_wismeshtap", "rak4631_nomadstar_meteor_pro", "rak3401-1watt", "t-echo-plus", "t-echo-lite") + listOf( + "rak2560", + "rak_wismeshtap", + "rak4631_nomadstar_meteor_pro", + "rak3401-1watt", + "t-echo-plus", + "t-echo-lite", + ) .forEach { target -> assertFalse( usbMaintenanceGate(nrf(target = target), FirmwareUpdateMethod.Usb, hasRelease = true) @@ -175,7 +182,7 @@ class UsbMaintenanceGateTest { } @Test - fun `board id selects the image, not the build target`() { + fun `board id selects the image rather than the build target`() { val rak = otafixUf2ForBoardId("WisBlock-RAK4631-Board") val techo = otafixUf2ForBoardId("nRF52840-TEcho-v1") @@ -254,9 +261,9 @@ class UsbMaintenanceGateTest { /** * Verbatim `INFO_UF2.TXT` from a RAK4631 on a stock 0.4.3 bootloader (May 2023), captured 2026-07-30. * - * Two things this vintage proves: the `SoftDevice:` line goes back at least this far, so the bundled-map fallback is - * belt-and-braces rather than the common path; and older bootloaders emit an extra `Ver:` line that 0.9.x dropped, - * which the line-scanning parser must tolerate. + * Two things this vintage proves: the `SoftDevice:` line goes back at least this far, so the bundled-map fallback + * is belt-and-braces rather than the common path; and older bootloaders emit an extra `Ver:` line that 0.9.x + * dropped, which the line-scanning parser must tolerate. */ private val rak4631StockInfo = "UF2 Bootloader 0.4.3\r\n" + @@ -320,7 +327,11 @@ class UsbMaintenanceGateTest { @Test fun `the drive outranks the bundled map`() { // The map says 6.1.1, the device says 7.3.0 with nothing else to go on — but they disagree, so refuse. - val conflict = resolveNrfEraseImage(mapped = SoftDeviceVariant.S140_6_1_1, reportedFromDrive = SoftDeviceVariant.S140_7_3_0) + val conflict = + resolveNrfEraseImage( + mapped = SoftDeviceVariant.S140_6_1_1, + reportedFromDrive = SoftDeviceVariant.S140_7_3_0, + ) assertTrue(conflict is EraseImageResolution.Conflict, "Disagreement must refuse, not pick a side") } diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 4fc39672ac6..ff5778770b3 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt @@ -207,14 +207,13 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl /** * Always false on desktop: there is no Storage Access Framework to classify a volume with, and the multi-pass UF2 - * maintenance flow is Android-only. Refusing is the fail-closed answer, consistent with - * [DesktopFirmwareUsbManager] reporting the CDC unblock unsupported. + * maintenance flow is Android-only. Refusing is the fail-closed answer, consistent with [DesktopFirmwareUsbManager] + * reporting the CDC unblock unsupported. */ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = false - override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) { - destinationUri.toLocalFileOrNull()?.canRead() == true - } + override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = + withContext(ioDispatcher) { destinationUri.toLocalFileOrNull()?.canRead() == true } /** * Directory-based equivalents of the Android tree operations. Reachable only if a desktop maintenance flow is ever From d3bac01efef38ced626b9462a1ea3f55907fec19 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 09:40:48 -0500 Subject: [PATCH 12/19] feat(firmware): surface factory erase and bootloader upgrade on the Ready state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the maintenance flow reachable. The gate was already computed and the actions already wired; nothing rendered them. Both actions are low-emphasis error-tinted text buttons behind confirmations, the same treatment BootloaderWarningCard uses, so neither competes with the primary update button. The erase confirmation says outright that channels, keys and settings are destroyed with no backup, and that the drive must be selected twice — once for the erase image, once for the firmware — because that is surprising if you meet it halfway through. A refused erase stays visible but disabled with its reason shown, since the reason is the useful part: it tells the user this app cannot confirm their device's SoftDevice and points them at the web flasher. An unmapped bootloader image is different and is simply absent — a coverage gap is not something a user can act on. AwaitingFileSave now keeps the screen on when the pass is destructive or is being retried. The pass queue lives in the ViewModel, so letting the screen sleep and the ViewModel clear mid-sequence would strand a device that already has no application. Previews cover the card available and refused, plus the erase pass with a retry message. They are internal rather than public so PreviewPublic passes without a new baseline entry. Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all pass. Co-Authored-By: Claude Opus 5 --- .../feature/firmware/FirmwarePreviews.kt | 61 ++++++++ .../feature/firmware/FirmwareUpdateScreen.kt | 130 ++++++++++++++++++ 2 files changed, 191 insertions(+) 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 c0c672c7431..60f5a6d5ba5 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 @@ -83,3 +83,64 @@ fun SuccessStatePreview() { fun DisclaimerDialogPreview() { AppTheme { Surface { DisclaimerDialog(updateMethod = FirmwareUpdateMethod.Ble, onDismiss = {}, onConfirm = {}) } } } + +@PreviewLightDark +@Composable +internal fun UsbMaintenanceCardPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.padding(24.dp)) { + UsbMaintenanceCard( + gate = UsbMaintenanceGate(show = true, showBootloaderUpgrade = true), + deviceName = "RAK4631", + onFactoryErase = {}, + onBootloaderUpgrade = {}, + ) + } + } + } +} + +@PreviewLightDark +@Composable +internal fun UsbMaintenanceCardRefusedPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.padding(24.dp)) { + UsbMaintenanceCard( + gate = + UsbMaintenanceGate( + show = true, + eraseRefusal = UsbMaintenanceRefusal.UnknownSoftDevice, + showBootloaderUpgrade = false, + ), + deviceName = "ThinkNode M8", + onFactoryErase = {}, + onBootloaderUpgrade = {}, + ) + } + } + } +} + +@PreviewLightDark +@Composable +internal fun AwaitingEraseFileSavePreview() { + AppTheme { + Surface { + Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + AwaitingFileSaveState( + state = + FirmwareUpdateState.AwaitingFileSave( + uf2Artifact = null, + fileName = null, + step = UsbFileSaveStep.FactoryErase, + retryMessage = UiText.DynamicString("That location isn't the device's update drive."), + ), + onSaveFile = {}, + onPickVolume = {}, + ) + } + } + } +} 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 4cc30c3cb71..66da8751c9a 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,7 +94,21 @@ 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_erase_action +import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_button +import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_text +import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_title +import org.meshtastic.core.resources.firmware_maintenance_no_release +import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume import org.meshtastic.core.resources.firmware_maintenance_select_drive +import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict +import org.meshtastic.core.resources.firmware_maintenance_unknown_board +import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice +import org.meshtastic.core.resources.firmware_maintenance_unsupported_device +import org.meshtastic.core.resources.firmware_maintenance_upgrade_bootloader_action +import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_text +import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_title +import org.meshtastic.core.resources.firmware_maintenance_wrong_destination 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 @@ -381,6 +395,11 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh is FirmwareUpdateState.Verifying, -> true + // A maintenance pass is mid-sequence: the device may already have no application, and the pass queue lives in + // the + // ViewModel, so letting the screen sleep (and the ViewModel clear) would strand the device. + is FirmwareUpdateState.AwaitingFileSave -> state.step.isDestructive || state.retryMessage != null + else -> false } @@ -491,6 +510,16 @@ private fun ReadyState( Spacer(Modifier.height(16.dp)) } + if (state.maintenance.show) { + UsbMaintenanceCard( + gate = state.maintenance, + deviceName = device.displayName, + onFactoryErase = actions.onFactoryErase, + onBootloaderUpgrade = actions.onBootloaderUpgrade, + ) + Spacer(Modifier.height(16.dp)) + } + if (state.isRecovery) { Text( text = stringResource(Res.string.firmware_recovery_explanation), @@ -734,6 +763,107 @@ private fun DeviceInfoCard( } } +/** + * Offers factory erase and, where available, an OTAFIX bootloader upgrade. + * + * Both are destructive, so both sit behind a confirmation and are rendered as low-emphasis error-tinted text buttons + * rather than anything that competes with the primary update action — the same treatment [BootloaderWarningCard] uses. + * + * An erase that cannot run safely stays **visible but disabled with its reason shown**, because the reason is the + * useful part: it tells the user this app can't confirm their device's SoftDevice and points them at the web flasher. A + * missing bootloader image is different and is simply absent — that is a coverage gap, not something a user can act on. + */ +@Composable +internal fun UsbMaintenanceCard( + gate: UsbMaintenanceGate, + deviceName: String, + onFactoryErase: () -> Unit, + onBootloaderUpgrade: () -> Unit, +) { + var showEraseConfirmation by remember { mutableStateOf(false) } + var showUpgradeConfirmation by remember { mutableStateOf(false) } + + if (showEraseConfirmation) { + MeshtasticDialog( + onDismiss = { showEraseConfirmation = false }, + title = stringResource(Res.string.firmware_maintenance_erase_confirm_title), + message = stringResource(Res.string.firmware_maintenance_erase_confirm_text, deviceName), + confirmText = stringResource(Res.string.firmware_maintenance_erase_confirm_button), + onConfirm = { + showEraseConfirmation = false + onFactoryErase() + }, + dismissText = stringResource(Res.string.cancel), + ) + } + + if (showUpgradeConfirmation) { + MeshtasticDialog( + onDismiss = { showUpgradeConfirmation = false }, + title = stringResource(Res.string.firmware_maintenance_upgrade_confirm_title), + message = stringResource(Res.string.firmware_maintenance_upgrade_confirm_text, deviceName), + confirmText = stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action), + onConfirm = { + showUpgradeConfirmation = false + onBootloaderUpgrade() + }, + dismissText = stringResource(Res.string.cancel), + ) + } + + Card(modifier = Modifier.fillMaxWidth().animateContentSize()) { + Column(modifier = Modifier.padding(16.dp)) { + TextButton( + onClick = { showEraseConfirmation = true }, + enabled = gate.eraseRefusal == null, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(Res.string.firmware_maintenance_erase_action)) + } + + gate.eraseRefusal?.let { refusal -> + Text( + text = usbMaintenanceRefusalText(refusal), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (gate.showBootloaderUpgrade) { + TextButton(onClick = { showUpgradeConfirmation = true }) { + Text(stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action)) + } + } + } + } +} + +/** + * Copy for a pre-flight refusal shown on the card. + * + * Mirrors the ViewModel's mapping for refusals raised mid-flow; the two exist separately because this one renders + * inline next to a disabled button while that one becomes an error state. + */ +@Composable +private fun usbMaintenanceRefusalText(refusal: UsbMaintenanceRefusal): String = when (refusal) { + UsbMaintenanceRefusal.UnknownSoftDevice -> stringResource(Res.string.firmware_maintenance_unknown_softdevice) + + UsbMaintenanceRefusal.UnsupportedArchitecture -> + stringResource(Res.string.firmware_maintenance_unsupported_device) + + UsbMaintenanceRefusal.NoFirmwareRelease -> stringResource(Res.string.firmware_maintenance_no_release) + + UsbMaintenanceRefusal.DestinationNotRemovable -> + stringResource(Res.string.firmware_maintenance_wrong_destination) + + UsbMaintenanceRefusal.NotABootloaderVolume -> + stringResource(Res.string.firmware_maintenance_not_a_bootloader_volume) + + UsbMaintenanceRefusal.SoftDeviceConflict -> stringResource(Res.string.firmware_maintenance_softdevice_conflict) + + UsbMaintenanceRefusal.UnknownBoardId -> stringResource(Res.string.firmware_maintenance_unknown_board) +} + @Composable private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) { val openUrl = rememberOpenUrl() From 40db0077c7ed2a24994ffb12120f08b324c3a885 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 09:50:28 -0500 Subject: [PATCH 13/19] fix(firmware): correct the claim that a .uf2 cannot update a bootloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped RAK4631 hint and the user docs both asserted that copying a `.uf2` will not update the bootloader. That is true of a vendor bootloader supplied as a SoftDevice+bootloader `.zip`, which really does need serial DFU — but false of a bootloader supplied as an `update-….uf2`, which the UF2 bootloader consumes itself. Verified: those images carry UF2 family id 0xD663823C (CFG_UF2_FAMILY_BOOT_ID) and ghostfat.c routes that family to its bootloader self-update branch. The claim is now scoped to the `.zip`, and both places point at the in-app bootloader upgrade over USB as the alternative. The docs also gain a section for factory erase and bootloader upgrade: what erasing destroys, that the drive is selected twice, and that the app reads INFO_UF2.TXT to confirm the volume and identify the board before writing — refusing, and pointing at the web flasher, when it cannot establish which SoftDevice is installed. Only the base strings.xml is touched; translations follow via Crowdin. Co-Authored-By: Claude Opus 5 --- .../src/commonMain/composeResources/values/strings.xml | 2 +- docs/en/user/firmware.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1c41ac6f084..ac32ba0a993 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -709,7 +709,7 @@ Open Meshtastic Flasher OTA update failed: %1$s This device's OTA loader rejected the requested update method: %1$s - For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader. + For RAK WisBlock RAK4631, the vendor\'s bootloader .zip has to be flashed with a serial DFU tool such as adafruit-nrfutil — copying that .zip to the device\'s drive won\'t work. Alternatively, connect this device over USB and use the bootloader upgrade in this app. Rebooting to DFU... Release Notes %1$s requires a target-matching .bin file. diff --git a/docs/en/user/firmware.md b/docs/en/user/firmware.md index c59b7093be3..7f6a3f62da5 100644 --- a/docs/en/user/firmware.md +++ b/docs/en/user/firmware.md @@ -43,7 +43,15 @@ The most common update method for Android users: When your radio is connected over **USB/serial** (rather than Bluetooth), the Firmware Update screen offers **USB File Transfer**. The app reboots the device into DFU mode, then prompts you to save the `.uf2` file to the device's DFU drive using the system file picker. This option appears only on a USB/serial connection — it is not available over Bluetooth. -> ℹ️ **nRF bootloader note:** Some devices (e.g. RAK WisBlock RAK4631) need their bootloader flashed with the vendor's serial DFU tool (such as `adafruit-nrfutil`) — copying the `.uf2` alone won't update the bootloader. The app surfaces a hint when this applies. +> ℹ️ **nRF bootloader note:** A vendor bootloader supplied as a `.zip` (e.g. RAK WisBlock RAK4631) has to be flashed with a serial DFU tool such as `adafruit-nrfutil` — copying that `.zip` to the drive won't work. A bootloader supplied as an `update-....uf2` **can** be installed by copying it to the drive; that is how the app's own bootloader upgrade works. The app surfaces a hint when the serial-only route applies. + +### Factory Erase and Bootloader Upgrade + +On a **USB/serial** connection, nRF52 and RP2040 devices also offer **Erase and reinstall** and, where an upgraded bootloader is published for the board, **Upgrade bootloader**. + +Erasing wipes everything on the device — channels, keys and all settings — and there is no backup, so the app asks for confirmation first. Both operations write two files in turn, so you will be asked to select the device's update drive twice: once for the erase or bootloader image, then again for the firmware. + +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. If it can't confirm which Bluetooth stack your device uses it refuses to erase and points you at the [Web Flasher](https://flasher.meshtastic.org) instead — picking wrong there can leave the device needing a hardware programmer to recover. ### Other Flashing Options From e7593fcf41a2bb6f818148d031a19e4eaea96436 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 10:01:40 -0500 Subject: [PATCH 14/19] test(firmware): guard the fail-closed SoftDevice map and the maintenance gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map is the one piece of this feature that fails silently. An unmapped or mistyped row does not break anything at runtime — it just makes factory erase quietly unavailable for that device — so nothing except CI would ever notice. SoftDeviceQuirkCoverageTest cross-checks the asset against the bundled hardware catalog: every nRF52840 model has a variant except an explicit known-unmapped allow-list (THINKNODE_M8, which has no firmware variant upstream to derive one from), every value maps to an image the app actually ships, every listed target exists in the catalog, and every catalog target is covered by its model's row — resolution is target-strict, so a stale target name is a silent refusal. It also pins MESH_TRACKER_X1 to 7.3.0, the entry the web flasher's allowlist omits. The guard was verified to fail, not just pass: dropping RAK4631's softDevice makes it fail with the missing hwModel named, and it passes again once restored. DeviceHardwareRepositoryImplTest covers all six resolution outcomes — resolved, absent asset, malformed asset, unmapped model, reported target not in the row, and an unrecognised value. The target-mismatch case is the important one: that is exactly where borrowing a sibling row's variant would write an erase image into a SoftDevice. ViewModel tests cover the gate per transport and architecture, and that starting a refused erase performs no download. Since performUsbMaintenance downloads before rebooting to DFU, no download also proves no reboot — FakeRadioController records nothing for rebootToDfu, and adding a counter to a shared double for one assertion was not worth it. Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all pass. Co-Authored-By: Claude Opus 5 --- .../firmware/SoftDeviceQuirkCoverageTest.kt | 153 ++++++++++++++++++ .../DeviceHardwareRepositoryImplTest.kt | 113 ++++++++++++- .../FirmwareUpdateViewModelFileTest.kt | 89 ++++++++++ 3 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt new file mode 100644 index 00000000000..f19f1c8c0db --- /dev/null +++ b/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt @@ -0,0 +1,153 @@ +/* + * 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 . + */ +package org.meshtastic.app.firmware + +import kotlinx.serialization.json.Json +import org.meshtastic.core.model.BootloaderOtaQuirksResponse +import org.meshtastic.core.model.NetworkDeviceHardware +import org.meshtastic.core.model.SoftDeviceVariant +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Guards the SoftDevice map in `device_bootloader_ota_quirks.json` against the hardware catalog. + * + * This test is the reason the map can safely be fail-closed. An unmapped or mistyped row does not break anything at + * runtime — it silently makes factory erase unavailable for that device — so nothing else would ever notice. CI has to + * be what notices. + * + * It deliberately does **not** assert which variant each board uses, only that every board has one and that the values + * are recognised. The variant itself is verified at runtime against what the device reports on its UF2 drive, and a + * disagreement refuses rather than guessing (see `resolveNrfEraseImage`). + */ +class SoftDeviceQuirkCoverageTest { + + /** + * Models with no firmware variant on `meshtastic/firmware` master, so no ldscript exists to derive a SoftDevice + * from. Present in the asset with no `softDevice` value, which makes the refusal deliberate and greppable rather + * than an oversight. Remove an entry here once its variant lands upstream. + */ + private val knownUnmapped = setOf(130) // THINKNODE_M8 + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `every nrf52840 model in the hardware catalog has a softdevice mapping`() { + val nrfModels = nrfHardware().map { it.hwModel }.toSet() + val mapped = quirks().softDeviceVariants.filter { it.softDevice != null }.map { it.hwModel }.toSet() + + val missing = nrfModels - mapped - knownUnmapped + assertTrue( + missing.isEmpty(), + "nRF52840 models with no SoftDevice mapping: $missing. Factory erase is silently unavailable for these. " + + "Derive each from meshtastic/firmware boards/.json build.arduino.ldscript " + + "(nrf52840_s140_v6.ld = 6.1.1, _v7.ld = 7.3.0), or add the hwModel to knownUnmapped with a reason.", + ) + } + + @Test + fun `known-unmapped models are present but deliberately carry no softdevice`() { + val entries = quirks().softDeviceVariants.associateBy { it.hwModel } + knownUnmapped.forEach { hwModel -> + val entry = entries[hwModel] + assertNotNull( + entry, + "hwModel $hwModel is listed as known-unmapped but has no row at all — add the row so " + + "the refusal is explicit, or drop it from knownUnmapped", + ) + assertEquals( + null, + entry.softDevice, + "hwModel $hwModel is listed as known-unmapped but now carries a SoftDevice — remove it from " + + "knownUnmapped instead", + ) + } + } + + @Test + fun `every softdevice value is one the app ships an erase image for`() { + quirks().softDeviceVariants.forEach { entry -> + val wire = entry.softDevice ?: return@forEach + assertNotNull( + SoftDeviceVariant.fromWire(wire), + "hwModel ${entry.hwModel} (${entry.hwModelSlug}) declares SoftDevice '$wire', which maps to no " + + "erase image. A typo here disables erase for that device silently.", + ) + } + } + + @Test + fun `every mapped target exists in the hardware catalog`() { + // Resolution requires the device-reported target to appear in its row, so a stale target name is a silent + // refusal for that build. + val catalogTargets = nrfHardware().map { it.platformioTarget }.toSet() + quirks().softDeviceVariants.forEach { entry -> + entry.platformioTargets.forEach { target -> + assertTrue( + target in catalogTargets, + "hwModel ${entry.hwModel} lists platformioTarget '$target', which is not in the hardware " + + "catalog — erase would be refused for any device reporting it", + ) + } + } + } + + @Test + fun `every nrf52840 catalog target is covered by its model's row`() { + val entries = quirks().softDeviceVariants.associateBy { it.hwModel } + nrfHardware().forEach { hw -> + val entry = entries[hw.hwModel] ?: return@forEach + assertTrue( + hw.platformioTargets().any { it in entry.platformioTargets }, + "hwModel ${hw.hwModel} (${hw.hwModelSlug}) builds target '${hw.platformioTarget}' but its row lists " + + "${entry.platformioTargets} — a device reporting that target would be refused", + ) + } + } + + @Test + fun `mesh tracker x1 is mapped, since the web flasher omits it`() { + // hwModel 128 is nrf52840_s140_v7.ld (7.3.0) but is absent from the web flasher's sd7.3 allowlist, so that tool + // serves it the 6.1.1 image. Pinned here so the app does not inherit the same gap. + val entry = quirks().softDeviceVariants.firstOrNull { it.hwModel == MESH_TRACKER_X1_HW_MODEL } + assertNotNull(entry, "MESH_TRACKER_X1 (128) must be mapped") + assertEquals(SoftDeviceVariant.S140_7_3_0, SoftDeviceVariant.fromWire(entry.softDevice)) + } + + private fun NetworkDeviceHardware.platformioTargets(): List = listOf(platformioTarget) + + private fun nrfHardware(): List = + json.decodeFromString>(asset("device_hardware.json").readText()).filter { + it.architecture == NRF52840_ARCHITECTURE + } + + private fun quirks(): BootloaderOtaQuirksResponse = + json.decodeFromString(asset("device_bootloader_ota_quirks.json").readText()) + + private fun asset(name: String): File = + listOf("src/main/assets/$name", "androidApp/src/main/assets/$name").map(::File).firstOrNull(File::exists) + ?: fail("Could not locate $name from working directory ${File(".").absolutePath}") + + private companion object { + const val NRF52840_ARCHITECTURE = "nrf52840" + const val MESH_TRACKER_X1_HW_MODEL = 128 + } +} diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt index 9d9c1af40c4..0b8c494fb57 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLinksResponse import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareReleases +import org.meshtastic.core.model.SoftDeviceVariant import org.meshtastic.core.network.DeviceHardwareRemoteDataSource import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.repository.DeviceLinkRepository @@ -46,6 +47,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -73,9 +75,13 @@ class DeviceHardwareRepositoryImplTest { private class FakeBundledAssetReader(var hardware: List, private val json: Json) : BundledAssetReader { - override fun open(name: String): Source? { - if (name != "device_hardware.json") return null - return Buffer().write(json.encodeToString(hardware).encodeToByteArray()) + /** Raw `device_bootloader_ota_quirks.json` body, or null to model the asset being absent entirely. */ + var quirksJson: String? = null + + override fun open(name: String): Source? = when (name) { + "device_hardware.json" -> Buffer().write(json.encodeToString(hardware).encodeToByteArray()) + "device_bootloader_ota_quirks.json" -> quirksJson?.let { Buffer().write(it.encodeToByteArray()) } + else -> null } } @@ -124,6 +130,7 @@ class DeviceHardwareRepositoryImplTest { private lateinit var databaseProvider: FakeDatabaseProvider private lateinit var api: FakeApiService private lateinit var links: FakeDeviceLinkRepository + private lateinit var assetReader: FakeBundledAssetReader private lateinit var repository: DeviceHardwareRepositoryImpl @BeforeTest @@ -131,11 +138,12 @@ class DeviceHardwareRepositoryImplTest { databaseProvider = FakeDatabaseProvider() api = FakeApiService(listOf(knownHardware)) links = FakeDeviceLinkRepository() + assetReader = FakeBundledAssetReader(listOf(knownHardware), json) repository = DeviceHardwareRepositoryImpl( remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers), localDataSource = DeviceHardwareLocalDataSource(databaseProvider), - assetReader = FakeBundledAssetReader(listOf(knownHardware), json), + assetReader = assetReader, json = json, deviceLinkRepository = links, dispatchers = dispatchers, @@ -199,6 +207,103 @@ class DeviceHardwareRepositoryImplTest { assertEquals(2, api.hardwareCalls) assertEquals(2, links.reconcileCalls) } + + // ── SoftDevice variant resolution: fail-closed on every unresolvable case ────────────────────── + + private val nrfHardware = + NetworkDeviceHardware( + hwModel = 9, + hwModelSlug = "RAK4631", + platformioTarget = "rak4631", + architecture = "nrf52840", + activelySupported = true, + displayName = "RAK4631", + images = listOf("rak4631.svg"), + ) + + private fun quirksAsset(hwModel: Int = 9, targets: String = "[\"rak4631\"]", softDevice: String? = "6.1.1") = + """ + { + "devices": [], + "softDeviceVariants": [ + { "hwModel": $hwModel, "hwModelSlug": "RAK4631", "platformioTargets": $targets + ${softDevice?.let { ", \"softDevice\": \"$it\"" } ?: ""} } + ] + } + """ + .trimIndent() + + /** Rebuilds the repository around an nRF fixture so SoftDevice resolution can be exercised. */ + private fun nrfRepository(quirks: String?): DeviceHardwareRepositoryImpl { + api = FakeApiService(listOf(nrfHardware)) + assetReader = FakeBundledAssetReader(listOf(nrfHardware), json).apply { quirksJson = quirks } + return DeviceHardwareRepositoryImpl( + remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers), + localDataSource = DeviceHardwareLocalDataSource(databaseProvider), + assetReader = assetReader, + json = json, + deviceLinkRepository = links, + dispatchers = dispatchers, + ) + } + + @Test + fun softDeviceResolvesWhenModelAndTargetMatch() = runBlocking { + val repo = nrfRepository(quirksAsset()) + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertEquals(SoftDeviceVariant.S140_6_1_1, hardware?.softDeviceVariant) + } + + @Test + fun softDeviceIsNullWhenTheAssetIsAbsent() = runBlocking { + val repo = nrfRepository(quirks = null) + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertNotNull(hardware, "hardware lookup must still succeed — only the variant is unavailable") + assertEquals(null, hardware.softDeviceVariant, "an absent asset must not resolve a variant") + } + + @Test + fun softDeviceIsNullWhenTheAssetIsMalformed() = runBlocking { + val repo = nrfRepository(quirks = "{ not json at all") + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertNotNull(hardware, "a malformed asset must not fail the hardware lookup") + assertEquals(null, hardware.softDeviceVariant) + } + + @Test + fun softDeviceIsNullWhenTheModelIsUnmapped() = runBlocking { + val repo = nrfRepository(quirksAsset(hwModel = 999)) + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertEquals(null, hardware?.softDeviceVariant, "an unmapped model must not borrow another row") + } + + @Test + fun softDeviceIsNullWhenTheReportedTargetIsNotInTheRow() = runBlocking { + // The dangerous case: hwModel matches but the device reports a build we have not verified. Borrowing the row's + // variant here is exactly what would write an erase image into a SoftDevice. + val repo = nrfRepository(quirksAsset(targets = "[\"rak4631_some_other_build\"]")) + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertEquals(null, hardware?.softDeviceVariant, "a target mismatch must refuse, not fall back") + } + + @Test + fun softDeviceIsNullWhenTheValueIsUnrecognised() = runBlocking { + val repo = nrfRepository(quirksAsset(softDevice = "6.1.2")) + + val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull() + + assertEquals(null, hardware?.softDeviceVariant, "an unknown SoftDevice string must not map to an image") + } } class DeviceHardwareRefreshGateTest { 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 3aaf7e38221..67640b283e1 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 @@ -24,6 +24,7 @@ import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode import dev.mokkery.verify.VerifyMode.Companion.atLeast import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend @@ -44,6 +45,7 @@ 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.model.SoftDeviceVariant import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.RadioPrefs @@ -715,4 +717,91 @@ class FirmwareUpdateViewModelFileTest { assertNull(viewModel.pendingLocalFirmwareFile.value) verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) } } + + // ── USB maintenance gating (factory erase / bootloader upgrade) ──────────────────────────────── + + @Test + fun `maintenance is offered for an nrf device over usb with a resolved softdevice`() = runTest { + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1)) + + viewModel = createViewModel() + advanceUntilIdle() + + val ready = assertIs(viewModel.state.value) + assertTrue(ready.maintenance.show, "nRF over USB should offer maintenance") + assertEquals(null, ready.maintenance.eraseRefusal, "a resolved SoftDevice must not refuse") + } + + @Test + fun `maintenance refuses erase when the softdevice is unresolved`() = runTest { + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(softDevice = null)) + + viewModel = createViewModel() + advanceUntilIdle() + + val ready = assertIs(viewModel.state.value) + assertTrue(ready.maintenance.show, "the action stays visible so the refusal can be explained") + assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, ready.maintenance.eraseRefusal) + } + + @Test + fun `maintenance is hidden over bluetooth`() = runTest { + // The flow needs the UF2 mass-storage volume, which only exists on a USB connection. + every { radioPrefs.devAddr } returns MutableStateFlow("x11:22:33:44:55:66") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1)) + + viewModel = createViewModel() + advanceUntilIdle() + + val ready = assertIs(viewModel.state.value) + assertFalse(ready.maintenance.show) + } + + @Test + fun `starting a refused erase performs no reboot and no download`() = runTest { + // Defence in depth behind the disabled button: a refused erase must not touch the device. + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(softDevice = null)) + + viewModel = createViewModel() + advanceUntilIdle() + assertIs(viewModel.state.value) + + viewModel.startFactoryErase() + advanceUntilIdle() + + assertIs(viewModel.state.value) + // performUsbMaintenance downloads the firmware before it reboots to DFU, so proving no download was attempted + // also proves the device was never rebooted. FakeRadioController.rebootToDfu records nothing to assert on. + verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } + } + + @Test + fun `writeMaintenancePass is ignored when no sequence is running`() = runTest { + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + viewModel = createViewModel() + advanceUntilIdle() + val before = viewModel.state.value + + viewModel.writeMaintenancePass(CommonUri.parse("content://tree/1234-5678%3A")) + advanceUntilIdle() + + assertEquals(before, viewModel.state.value, "a stray volume pick must not change state") + } + + private fun nrfHardware(softDevice: SoftDeviceVariant?) = DeviceHardware( + hwModel = 9, + hwModelSlug = "RAK4631", + platformioTarget = "rak4631", + architecture = "nrf52840", + displayName = "RAK4631", + activelySupported = true, + softDeviceVariant = softDevice, + ) } From 00961c9a99c8f4923170a2a5dc73571d58d25086 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 10:34:09 -0500 Subject: [PATCH 15/19] feat(firmware): stop the radio transport claiming the device mid-maintenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last of the four P0 mitigations. A maintenance sequence leaves the device enumerating as bare erase or bootloader firmware with no Meshtastic protocol on it, and two paths would bind a transport to it anyway: the environmental-recovery listeners re-enter startTransportLocked whenever Bluetooth or the network flips, and SerialRadioTransport.connect() falls back to `deviceMap.values.firstOrNull()` rather than requiring the saved address. FirmwareMaintenanceLock lives in :core:common because the two parties cannot see each other — the flow that takes it is in :feature:firmware, the code that must respect it is in :core:service. Checked alongside connectionRequested at both environmental-recovery sites and inside observeUsbRecoveryTriggers. The ViewModel takes it for the whole sequence and releases on every exit: completion (before verify, so the normal reconnect can run), preparation failure that produced no passes, and giving up before anything destructive happened. It is deliberately held across a retry, since the sequence is still live. Not destructive if it were missing — a transport that claims the port asserts DTR itself, which happens to unblock the erase — but it made the flow's own success signal unreliable and put a mesh handshake against erase firmware in the logs. Tested at the ViewModel level: a refused erase never takes the lock, and a failed preparation releases it. A service-level integration test was attempted and abandoned: driving environmental recovery through that harness hangs, and the file already carries a comment warning about a mutex deadlock in exactly that area. Recorded as deferred rather than left hanging in the suite. Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all pass. Co-Authored-By: Claude Opus 5 --- .../common/state/FirmwareMaintenanceLock.kt | 54 +++++++++++++++++++ .../service/SharedRadioInterfaceService.kt | 9 +++- ...SharedRadioInterfaceServiceLivenessTest.kt | 3 ++ .../firmware/FirmwareUpdateViewModel.kt | 23 +++++--- .../firmware/FirmwareUpdateIntegrationTest.kt | 2 + .../firmware/FirmwareUpdateViewModelTest.kt | 2 + .../FirmwareUpdateViewModelFileTest.kt | 37 +++++++++++++ 7 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt new file mode 100644 index 00000000000..0b273d23359 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt @@ -0,0 +1,54 @@ +/* + * 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 . + */ +package org.meshtastic.core.common.state + +import kotlinx.atomicfu.atomic +import org.koin.core.annotation.Single + +/** + * Marks a USB firmware-maintenance sequence as in flight, so the radio transport does not fight it for the device. + * + * A factory erase leaves the device enumerating as a bare CDC port with no Meshtastic protocol on it. The serial + * transport will happily bind to that — its device lookup falls back to whatever probed device it can find, not just + * the saved address — and the environmental-recovery listeners re-enter transport startup whenever Bluetooth or the + * network flips. Both would claim the port the maintenance flow needs. + * + * Lives in `:core:common` because the two parties sit in modules that cannot see each other: the flow that takes the + * lock is in `:feature:firmware`, and the code that must respect it is in `:core:service`. + * + * The consequence of ignoring the lock is bounded rather than destructive — a transport that claims the port asserts + * DTR itself, which happens to unblock the erase — but it makes the flow's own signal unreliable, and a mesh handshake + * against erase firmware is noise nobody needs to debug. + */ +@Single +class FirmwareMaintenanceLock { + private val active = atomic(false) + + /** True while a maintenance sequence holds the lock. */ + val isActive: Boolean + get() = active.value + + /** Takes the lock. Idempotent — re-taking an already-held lock is a no-op. */ + fun acquire() { + active.value = true + } + + /** Releases the lock. Must run even on failure, so callers use `try`/`finally`. */ + fun release() { + active.value = false + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 4b6f362a561..9ea8c20051c 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -54,6 +54,7 @@ import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis @@ -163,6 +164,7 @@ class SharedRadioInterfaceService( private val radioPrefs: RadioPrefs, private val transportFactory: RadioTransportFactory, private val analytics: PlatformAnalytics, + private val firmwareMaintenanceLock: FirmwareMaintenanceLock, ) : RadioInterfaceService { override val supportedDeviceTypes: List @@ -474,7 +476,7 @@ class SharedRadioInterfaceService( // explicitly disconnected from. stopTransportLocked() below still fires on // BLE-disabled to tear down a running BLE link, but we deliberately do NOT // clear connectionRequested here — that is disconnect()'s job. - if (connectionRequested) { + if (connectionRequested && !firmwareMaintenanceLock.isActive) { startTransportLocked() } } else if (runningTransportId == InterfaceId.BLUETOOTH) { @@ -490,7 +492,7 @@ class SharedRadioInterfaceService( transportMutex.withLock { if (state) { // Environmental recovery only — see the BLE listener above for rationale. - if (connectionRequested) { + if (connectionRequested && !firmwareMaintenanceLock.isActive) { startTransportLocked() } } else if (runningTransportId == InterfaceId.TCP) { @@ -530,6 +532,9 @@ class SharedRadioInterfaceService( .onEach { transportMutex.withLock { if (!connectionRequested) return@withLock + // A USB firmware-maintenance sequence owns the port: the device is enumerating as bare erase or + // bootloader firmware, and binding a mesh transport to it would claim the port the flow needs. + if (firmwareMaintenanceLock.isActive) return@withLock if (runningTransportId != InterfaceId.SERIAL) return@withLock // Race-defense: the combine snapshot may be stale by the time we acquire // transportMutex — another path (setDeviceAddress, BLE liveness restart) may diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt index ea7a762857c..75d97dad5da 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType @@ -151,6 +152,7 @@ class SharedRadioInterfaceServiceLivenessTest { private val networkRepository: NetworkRepository = mock(MockMode.autofill) private val analytics: PlatformAnalytics = mock(MockMode.autofill) + private val firmwareMaintenanceLock = FirmwareMaintenanceLock() /** * Minimal [LifecycleOwner] for tests that avoids [LifecycleRegistry], which enforces main-thread checks and throws @@ -277,6 +279,7 @@ class SharedRadioInterfaceServiceLivenessTest { radioPrefs = radioPrefs, transportFactory = transportFactory, analytics = analytics, + firmwareMaintenanceLock = firmwareMaintenanceLock, ) service.clockMillis = { clock } // Register the service so tearDown can disconnect it deterministically (the heartbeat loop 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 c7659380305..2ae0ad1e9dc 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.di.ApplicationCoroutineScope +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.safeCatching @@ -122,6 +123,7 @@ class FirmwareUpdateViewModel( private val usbManager: FirmwareUsbManager, private val fileHandler: FirmwareFileHandler, private val firmwareRetriever: FirmwareRetriever, + private val firmwareMaintenanceLock: FirmwareMaintenanceLock, private val applicationScope: ApplicationCoroutineScope, private val hiddenFeaturesUnlock: HiddenFeaturesUnlock, ) : ViewModel() { @@ -511,6 +513,9 @@ class FirmwareUpdateViewModel( originalDeviceAddress = radioPrefs.devAddr.value maintenanceHardware = currentState.deviceHardware destructiveWriteDone = false + // Held until the sequence finishes or fails. Without it the environmental-recovery listeners restart the radio + // transport mid-sequence and bind it to the erase firmware's bare CDC port. + firmwareMaintenanceLock.acquire() viewModelScope.launch { if (!checkBatteryLevel()) return@launch @@ -536,6 +541,9 @@ class FirmwareUpdateViewModel( } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { Logger.e(e) { "USB maintenance preparation failed" } _state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed)) + } finally { + // Preparation that produced no passes never reached the device; hand the transport back. + if (pendingUsbPasses.isEmpty()) firmwareMaintenanceLock.release() } } } @@ -595,6 +603,8 @@ class FirmwareUpdateViewModel( val next = pendingUsbPasses.firstOrNull() if (next == null) { + // Sequence complete: hand the device back before verifying, so the normal reconnect can run. + firmwareMaintenanceLock.release() verifyUpdateResult(originalDeviceAddress) } else { _state.value = next.toAwaitingFileSave() @@ -609,12 +619,13 @@ class FirmwareUpdateViewModel( * deliberately. */ private fun reofferOrFail(pass: UsbFileSavePass, message: UiText) { - _state.value = - if (destructiveWriteDone) { - pass.toAwaitingFileSave(retryMessage = message) - } else { - FirmwareUpdateState.Error(message) - } + if (destructiveWriteDone) { + // Still mid-sequence — hold the lock, the user is being asked to retry this pass. + _state.value = pass.toAwaitingFileSave(retryMessage = message) + } else { + firmwareMaintenanceLock.release() + _state.value = FirmwareUpdateState.Error(message) + } } private fun usbPassWriter(portsBefore: Set) = UsbPassWriter( diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index c22ae33a082..7eb304862c7 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -115,6 +116,7 @@ class FirmwareUpdateIntegrationTest { usbManager, fileHandler, firmwareRetriever, + FirmwareMaintenanceLock(), TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), ) diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index f201bf5ca5f..1561a0c2fea 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType @@ -138,6 +139,7 @@ class FirmwareUpdateViewModelTest { usbManager, fileHandler, firmwareRetriever, + FirmwareMaintenanceLock(), TestApplicationCoroutineScope(testDispatcher), hiddenFeaturesUnlock, ) 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 67640b283e1..74e36c94afd 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.state.FirmwareMaintenanceLock import org.meshtastic.core.common.state.HiddenFeaturesUnlock import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.FirmwareRelease @@ -85,6 +86,7 @@ class FirmwareUpdateViewModelFileTest { private val usbManager: FirmwareUsbManager = mock(MockMode.autofill) private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill) private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill) + private val firmwareMaintenanceLock = FirmwareMaintenanceLock() private lateinit var viewModel: FirmwareUpdateViewModel @@ -139,6 +141,7 @@ class FirmwareUpdateViewModelFileTest { usbManager, fileHandler, firmwareRetriever, + firmwareMaintenanceLock, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), ) @@ -804,4 +807,38 @@ class FirmwareUpdateViewModelFileTest { activelySupported = true, softDeviceVariant = softDevice, ) + + @Test + fun `a refused erase never takes the maintenance lock`() = runTest { + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(softDevice = null)) + + viewModel = createViewModel() + advanceUntilIdle() + viewModel.startFactoryErase() + advanceUntilIdle() + + assertFalse( + firmwareMaintenanceLock.isActive, + "refusing before the sequence starts must leave the radio transport unblocked", + ) + } + + @Test + fun `a failed preparation releases the maintenance lock`() = runTest { + // The lock suppresses transport restarts; leaking it would leave the app unable to reconnect at all. + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1)) + everySuspend { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } returns null + + viewModel = createViewModel() + advanceUntilIdle() + viewModel.startFactoryErase() + advanceUntilIdle() + + assertIs(viewModel.state.value) + assertFalse(firmwareMaintenanceLock.isActive, "a failed preparation must not leak the lock") + } } From 22866de5944b6d9138aa5794d76ce1ca73b17cba Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 14:19:34 -0500 Subject: [PATCH 16/19] fix(firmware): release the maintenance lock when the sequence's final pass completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: FirmwareMaintenanceLock was acquired for every erase/upgrade sequence but only ever released from advancePastPass, which is reachable solely through writeMaintenancePass — the path for a FromVolume pass whose image is chosen from the mounted drive. The sequence's terminal pass (the release firmware, a Prepared pass with a known filename) is saved through the pre-existing saveDfuFile instead, which had no idea the lock existed. The result: every SUCCESSFUL factory erase or bootloader upgrade leaked the lock for the rest of the process. verifyUpdateResult deliberately does not force-reconnect over USB/serial — it relies entirely on SharedRadioInterfaceService.observeUsbRecoveryTriggers noticing the re-enumerated device, and that is exactly the recovery path the lock suppresses. So the app would sit at Verifying until timeout and report VerificationFailed on every successful run, and — because the lock is a Koin singleton — silently block BLE/network-triggered reconnection for any other device for the rest of the session, not just the one just flashed. saveDfuFile's finally now releases the lock and clears the sequence's bookkeeping (pendingUsbPasses, maintenanceHardware) unconditionally; it is a no-op for a plain single-pass update, which never acquires the lock in the first place. onCleared() also releases it, closing the secondary leak where a user abandons the flow between passes. Added a regression test that drives a full two-pass FactoryErase sequence through writeMaintenancePass then saveDfuFile and asserts the lock is released afterward — confirmed to fail at the exact assertion with the fix reverted, and pass with it restored, before landing this commit. Gate: spotlessApply, spotlessCheck, detekt, assembleDebug, test allTests — all pass. Co-Authored-By: Claude Opus 5 --- .../firmware/FirmwareUpdateViewModel.kt | 12 +++++ .../FirmwareUpdateViewModelFileTest.kt | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+) 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 2ae0ad1e9dc..cec9372ecfe 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 @@ -185,6 +185,11 @@ class FirmwareUpdateViewModel( super.onCleared() prepareJob?.cancel() prepareJob = null + // A maintenance sequence's lock must not outlive this ViewModel: the user may abandon the flow between + // passes (e.g. navigating away after the erase leg but before picking the firmware save location), and a + // leaked lock would permanently suppress the radio transport's auto-reconnect for the rest of the app + // session — see startFactoryErase/advancePastPass. A no-op if no sequence was in flight. + firmwareMaintenanceLock.release() // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the // application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable // context keeps cleanup running even if something tries to cancel it mid-flight. @@ -684,6 +689,13 @@ class FirmwareUpdateViewModel( _state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed)) } finally { cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + // This is also the terminal pass of a USB maintenance sequence when the FromVolume leg has already + // completed: the Prepared firmware artifact is saved through this pre-existing single-pass path + // rather than writeMaintenancePass/advancePastPass, so releasing here is the only place that + // sequence's lock gets freed. A no-op for a plain single-pass update, which never acquires the lock. + firmwareMaintenanceLock.release() + pendingUsbPasses = emptyList() + maintenanceHardware = null } } } 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 74e36c94afd..2e174871cd0 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 @@ -841,4 +841,57 @@ class FirmwareUpdateViewModelFileTest { assertIs(viewModel.state.value) assertFalse(firmwareMaintenanceLock.isActive, "a failed preparation must not leak the lock") } + + @Test + fun `completing the firmware pass of a maintenance sequence releases the lock`() = runTest { + // Regression: the FromVolume (erase) pass releases the lock through advancePastPass, but the sequence's + // final Prepared (firmware) pass is saved through the pre-existing saveDfuFile — which had no idea the lock + // existed. That left every SUCCESSFUL erase/upgrade leaking the lock forever, permanently suppressing the + // radio transport's auto-reconnect for the rest of the app session (see SharedRadioInterfaceService). + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1)) + + val firmwareArtifact = + FirmwareArtifact(uri = CommonUri.parse("file:///tmp/firmware.uf2"), fileName = "firmware.uf2") + val eraseArtifact = FirmwareArtifact(uri = CommonUri.parse("file:///tmp/erase.uf2"), fileName = "erase.uf2") + everySuspend { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } returns firmwareArtifact + everySuspend { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) } returns eraseArtifact + + // No SoftDevice line on the volume — falls back to the (resolved) map variant, matching every other case + // this suite already covers for that fallback. + everySuspend { fileHandler.isRemovableDestination(any()) } returns true + everySuspend { fileHandler.readSiblingText(any(), any()) } returns "Board-ID: Test-Board\r\n" + everySuspend { fileHandler.createDocumentInTree(any(), any(), any()) } returns + CommonUri.parse("content://tree/1234-5678%3A/document/erase.uf2") + everySuspend { fileHandler.copyToUri(any(), any()) } returns 1024L + every { usbManager.deviceDetachFlow() } returns flowOf(Unit) + everySuspend { usbManager.serialPortKeys() } returns emptySet() + everySuspend { usbManager.unblockCdcPort(any(), any(), any()) } returns true + + viewModel = createViewModel() + advanceUntilIdle() + viewModel.startFactoryErase() + advanceUntilIdle() + + // First pass (erase image) — writes through writeMaintenancePass. + val awaitingErase = assertIs(viewModel.state.value) + assertEquals(UsbFileSaveStep.FactoryErase, awaitingErase.step) + viewModel.writeMaintenancePass(CommonUri.parse("content://tree/1234-5678%3A")) + advanceUntilIdle() + + assertTrue(firmwareMaintenanceLock.isActive, "the lock must still be held between passes") + val awaitingFirmware = assertIs(viewModel.state.value) + assertEquals(UsbFileSaveStep.Firmware, awaitingFirmware.step) + assertNotNull(awaitingFirmware.uf2Artifact, "the terminal pass must carry its artifact") + + // Second, terminal pass (firmware image) — writes through the pre-existing saveDfuFile. + viewModel.saveDfuFile(CommonUri.parse("file:///output/firmware.uf2")) + advanceUntilIdle() + + assertFalse( + firmwareMaintenanceLock.isActive, + "completing the sequence's terminal pass must release the lock, or auto-reconnect stays suppressed forever", + ) + } } From 28a9548c4f7d9a6adbc762cc40e2bf4bf82cde35 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 14:37:29 -0500 Subject: [PATCH 17/19] fix(firmware): refuse a bootloader upgrade the maintenance gate never offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startUsbMaintenance already refused a FactoryErase request when eraseRefusal was set, but had no equivalent check for BootloaderUpgrade against showBootloaderUpgrade. A stray call on a device the UI would never show the button for (ESP32, or any nRF board OTAFIX ships no bootloader for) would download firmware and reboot the device into DFU mode before the write-time chooseMaintenanceImage check finally refused it — a pointless reboot cycle, found during the sequential in-session correctness review. Co-Authored-By: Claude Opus 5 --- .../firmware/FirmwareUpdateViewModel.kt | 23 ++++++++++++++----- .../FirmwareUpdateViewModelFileTest.kt | 21 +++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) 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 cec9372ecfe..931376ee4c4 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 @@ -507,12 +507,23 @@ class FirmwareUpdateViewModel( val currentState = _state.value as? FirmwareUpdateState.Ready ?: return val release = currentState.release ?: return - // Defence in depth: the screen already disables a refused erase, but never start one on a device whose - // SoftDevice we could not resolve. - val refusal = currentState.maintenance.eraseRefusal - if (request == UsbMaintenanceRequest.FactoryErase && refusal != null) { - _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalError(refusal)) - return + // Defence in depth: the screen already disables a refused erase and hides an unmapped bootloader upgrade, + // but never reboot a device into DFU for a request the gate would not have offered — that costs the user a + // pointless reboot cycle before the write-time check in chooseMaintenanceImage refuses it anyway. + val gate = currentState.maintenance + when (request) { + UsbMaintenanceRequest.FactoryErase -> + gate.eraseRefusal?.let { + _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalError(it)) + return + } + + UsbMaintenanceRequest.BootloaderUpgrade -> + if (!gate.showBootloaderUpgrade) { + _state.value = + FirmwareUpdateState.Error(usbMaintenanceRefusalError(UsbMaintenanceRefusal.UnknownBoardId)) + return + } } originalDeviceAddress = radioPrefs.devAddr.value 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 2e174871cd0..3840a0ae26f 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 @@ -785,6 +785,27 @@ class FirmwareUpdateViewModelFileTest { verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } } + @Test + fun `starting a bootloader upgrade the gate would not offer performs no reboot and no download`() = runTest { + // Defence in depth: an ESP32 has no bootloader-upgrade action to show in the first place + // (showBootloaderUpgrade is nRF-only), so a stray call must refuse before touching the device. + every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") + everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns + Result.success(DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")) + + viewModel = createViewModel() + advanceUntilIdle() + val ready = assertIs(viewModel.state.value) + assertFalse(ready.maintenance.showBootloaderUpgrade) + + viewModel.startBootloaderUpgrade() + advanceUntilIdle() + + assertIs(viewModel.state.value) + verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } + assertFalse(firmwareMaintenanceLock.isActive, "a refused request must never take the maintenance lock") + } + @Test fun `writeMaintenancePass is ignored when no sequence is running`() = runTest { every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0") From 079a4d64d0e346a3e124fcc71375d047789895db Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 14:59:21 -0500 Subject: [PATCH 18/19] refine(firmware): fix the three deferred review findings - parseUf2BoardId now trims leading whitespace before matching Board-ID:, matching parseUf2SoftDevice's existing behavior for SoftDevice: (F-6). - Document the USB maintenance (factory erase / OTAFIX bootloader upgrade) capability in feature/firmware/README.md, which had no mention of it despite ~10 new files (F-7). - Deduplicate the UsbMaintenanceRefusal -> copy mapping that existed twice (once in the ViewModel, once in the Composable card) into a single usbMaintenanceRefusalMessage() in UsbMaintenance.kt (F-8). Co-Authored-By: Claude Opus 5 --- feature/firmware/README.md | 30 ++++++++++++++++ .../feature/firmware/FirmwareUpdateScreen.kt | 33 +++-------------- .../firmware/FirmwareUpdateViewModel.kt | 33 ++--------------- .../feature/firmware/MaintenanceUf2.kt | 2 +- .../feature/firmware/UsbMaintenance.kt | 35 +++++++++++++++++++ .../firmware/UsbMaintenanceGateTest.kt | 5 +++ 6 files changed, 79 insertions(+), 59 deletions(-) diff --git a/feature/firmware/README.md b/feature/firmware/README.md index 226fecc557a..80f18998d7e 100644 --- a/feature/firmware/README.md +++ b/feature/firmware/README.md @@ -76,6 +76,33 @@ sequenceDiagram USB->>USB: Auto-Flash & Reboot ``` +#### 4. USB Maintenance: Factory Erase & OTAFIX Bootloader Upgrade +An nRF52/RP2040 device already in UF2 bootloader mode can also run a **factory erase** (wipes the internal filesystem, useful for a device stuck in a bad state) or, on boards OTAFIX ships a bootloader for, a **bootloader self-update**. Both are two-pass sequences: the maintenance image (erase or OTAFIX) is written first, which reboots the device back into a bare bootloader; the release firmware is then written as the second, ordinary UF2 pass. + +Two runtime facts make the maintenance image itself safety-critical, not just another UF2 write: + +- **The nRF52 erase image 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`). +- **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. + +```mermaid +sequenceDiagram + participant App as Android App + participant Radio as Mesh Node + participant USB as USB Mass Storage + + App->>Radio: rebootToDfu() + Radio->>Radio: Mounts as UF2 bootloader drive + App->>USB: Read INFO_UF2.TXT (Board-ID, SoftDevice) + App->>App: Resolve erase/OTAFIX image, verify digest + target address + App->>USB: Write maintenance image + USB->>USB: Auto-flash & reboot to bare bootloader + App->>App: Prompt User to Save release firmware + App->>USB: Write firmware.uf2 (pass 2) + USB->>USB: Auto-Flash & Reboot +``` + +A `FirmwareMaintenanceLock` (`:core:common`) is held for the duration of the sequence so `SharedRadioInterfaceService`'s environmental-recovery listeners don't claim the erase firmware's bare CDC port out from under the flow; it is released when the sequence's terminal pass completes, fails, or the ViewModel is cleared mid-sequence. + ### Key Classes - `FirmwareUpdateManager.kt`: Top-level orchestrator for all firmware update flows. @@ -90,6 +117,9 @@ sequenceDiagram - `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 tables, `INFO_UF2.TXT` parsing (Board-ID, SoftDevice), and the drive-vs-map SoftDevice resolution used to pick a safe erase image. +- `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. ## Dependency Graph 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 66da8751c9a..e33ebe7b3f7 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 @@ -98,17 +98,10 @@ import org.meshtastic.core.resources.firmware_maintenance_erase_action import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_button import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_text import org.meshtastic.core.resources.firmware_maintenance_erase_confirm_title -import org.meshtastic.core.resources.firmware_maintenance_no_release -import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume import org.meshtastic.core.resources.firmware_maintenance_select_drive -import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict -import org.meshtastic.core.resources.firmware_maintenance_unknown_board -import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice -import org.meshtastic.core.resources.firmware_maintenance_unsupported_device import org.meshtastic.core.resources.firmware_maintenance_upgrade_bootloader_action import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_text import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_title -import org.meshtastic.core.resources.firmware_maintenance_wrong_destination 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 @@ -839,30 +832,14 @@ internal fun UsbMaintenanceCard( } /** - * Copy for a pre-flight refusal shown on the card. + * Copy for a pre-flight refusal shown inline next to the disabled button. * - * Mirrors the ViewModel's mapping for refusals raised mid-flow; the two exist separately because this one renders - * inline next to a disabled button while that one becomes an error state. + * Resolves the same [usbMaintenanceRefusalMessage] mapping the ViewModel uses for a refusal raised mid-flow, so the two + * call sites can never drift out of sync with each other. */ @Composable -private fun usbMaintenanceRefusalText(refusal: UsbMaintenanceRefusal): String = when (refusal) { - UsbMaintenanceRefusal.UnknownSoftDevice -> stringResource(Res.string.firmware_maintenance_unknown_softdevice) - - UsbMaintenanceRefusal.UnsupportedArchitecture -> - stringResource(Res.string.firmware_maintenance_unsupported_device) - - UsbMaintenanceRefusal.NoFirmwareRelease -> stringResource(Res.string.firmware_maintenance_no_release) - - UsbMaintenanceRefusal.DestinationNotRemovable -> - stringResource(Res.string.firmware_maintenance_wrong_destination) - - UsbMaintenanceRefusal.NotABootloaderVolume -> - stringResource(Res.string.firmware_maintenance_not_a_bootloader_volume) - - UsbMaintenanceRefusal.SoftDeviceConflict -> stringResource(Res.string.firmware_maintenance_softdevice_conflict) - - UsbMaintenanceRefusal.UnknownBoardId -> stringResource(Res.string.firmware_maintenance_unknown_board) -} +private fun usbMaintenanceRefusalText(refusal: UsbMaintenanceRefusal): String = + usbMaintenanceRefusalMessage(refusal).asString() @Composable private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) { 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 931376ee4c4..23b6e433141 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 @@ -65,12 +65,6 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.firmware_maintenance_cdc_unblock_failed import org.meshtastic.core.resources.firmware_maintenance_copy_failed -import org.meshtastic.core.resources.firmware_maintenance_no_release -import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume -import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict -import org.meshtastic.core.resources.firmware_maintenance_unknown_board -import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice -import org.meshtastic.core.resources.firmware_maintenance_unsupported_device import org.meshtastic.core.resources.firmware_maintenance_wrong_destination import org.meshtastic.core.resources.firmware_recovery_ble_failed import org.meshtastic.core.resources.firmware_update_archive_missing_target @@ -514,14 +508,14 @@ class FirmwareUpdateViewModel( when (request) { UsbMaintenanceRequest.FactoryErase -> gate.eraseRefusal?.let { - _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalError(it)) + _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(it)) return } UsbMaintenanceRequest.BootloaderUpgrade -> if (!gate.showBootloaderUpgrade) { _state.value = - FirmwareUpdateState.Error(usbMaintenanceRefusalError(UsbMaintenanceRefusal.UnknownBoardId)) + FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(UsbMaintenanceRefusal.UnknownBoardId)) return } } @@ -598,7 +592,7 @@ class FirmwareUpdateViewModel( private suspend fun handlePassResult(pass: UsbFileSavePass, result: UsbPassResult) = when (result) { UsbPassResult.Written -> advancePastPass(pass) - is UsbPassResult.Refused -> reofferOrFail(pass, usbMaintenanceRefusalError(result.reason)) + is UsbPassResult.Refused -> reofferOrFail(pass, usbMaintenanceRefusalMessage(result.reason)) UsbPassResult.ImageDownloadFailed -> reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_retrieval_failed)) @@ -655,27 +649,6 @@ class FirmwareUpdateViewModel( unblockCdc = { wait, hold -> usbManager.unblockCdcPort(portsBefore, wait, hold) }, ) - private fun usbMaintenanceRefusalError(reason: UsbMaintenanceRefusal): UiText = when (reason) { - UsbMaintenanceRefusal.UnknownSoftDevice -> - UiText.Resource(Res.string.firmware_maintenance_unknown_softdevice) - - UsbMaintenanceRefusal.UnsupportedArchitecture -> - UiText.Resource(Res.string.firmware_maintenance_unsupported_device) - - UsbMaintenanceRefusal.NoFirmwareRelease -> UiText.Resource(Res.string.firmware_maintenance_no_release) - - UsbMaintenanceRefusal.DestinationNotRemovable -> - UiText.Resource(Res.string.firmware_maintenance_wrong_destination) - - UsbMaintenanceRefusal.NotABootloaderVolume -> - UiText.Resource(Res.string.firmware_maintenance_not_a_bootloader_volume) - - UsbMaintenanceRefusal.SoftDeviceConflict -> - UiText.Resource(Res.string.firmware_maintenance_softdevice_conflict) - - UsbMaintenanceRefusal.UnknownBoardId -> UiText.Resource(Res.string.firmware_maintenance_unknown_board) - } - fun saveDfuFile(uri: CommonUri) { val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return // A maintenance pass carries no artifact — it goes through writeMaintenancePass with a tree URI instead. 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 fb0620b76e9..2e3c624f468 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 @@ -256,7 +256,7 @@ internal val otafixBoardIds: Set */ internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text .lineSequence() - .firstOrNull { it.startsWith(UF2_BOARD_ID_PREFIX, ignoreCase = true) } + .firstOrNull { it.trimStart().startsWith(UF2_BOARD_ID_PREFIX, ignoreCase = true) } ?.substringAfter(':') ?.trim() ?.takeIf { it.isNotEmpty() } 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 e382f57f844..e3260421760 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 @@ -19,6 +19,15 @@ package org.meshtastic.feature.firmware import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.SoftDeviceVariant +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.firmware_maintenance_no_release +import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume +import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict +import org.meshtastic.core.resources.firmware_maintenance_unknown_board +import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice +import org.meshtastic.core.resources.firmware_maintenance_unsupported_device +import org.meshtastic.core.resources.firmware_maintenance_wrong_destination /** * Which leg of a multi-pass USB/UF2 sequence a file-save prompt belongs to. @@ -89,6 +98,32 @@ enum class UsbMaintenanceRefusal { UnknownBoardId, } +/** + * The single mapping from [UsbMaintenanceRefusal] to user-facing copy. + * + * Shared by the ViewModel (wraps it in [org.meshtastic.feature.firmware.FirmwareUpdateState.Error] for a refusal raised + * mid-flow) and the pre-flight card (resolves it via [UiText.asString] next to the disabled button) so the two call + * sites can never drift out of sync with each other. + */ +internal fun usbMaintenanceRefusalMessage(reason: UsbMaintenanceRefusal): UiText = when (reason) { + UsbMaintenanceRefusal.UnknownSoftDevice -> UiText.Resource(Res.string.firmware_maintenance_unknown_softdevice) + + UsbMaintenanceRefusal.UnsupportedArchitecture -> + UiText.Resource(Res.string.firmware_maintenance_unsupported_device) + + UsbMaintenanceRefusal.NoFirmwareRelease -> UiText.Resource(Res.string.firmware_maintenance_no_release) + + UsbMaintenanceRefusal.DestinationNotRemovable -> + UiText.Resource(Res.string.firmware_maintenance_wrong_destination) + + UsbMaintenanceRefusal.NotABootloaderVolume -> + UiText.Resource(Res.string.firmware_maintenance_not_a_bootloader_volume) + + UsbMaintenanceRefusal.SoftDeviceConflict -> UiText.Resource(Res.string.firmware_maintenance_softdevice_conflict) + + UsbMaintenanceRefusal.UnknownBoardId -> UiText.Resource(Res.string.firmware_maintenance_unknown_board) +} + /** * Which maintenance affordances the Ready state should show, precomputed by the ViewModel. * 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 dc015329c6f..2babb9c94ba 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 @@ -230,6 +230,11 @@ class UsbMaintenanceGateTest { assertNull(parseUf2BoardId(""), "A volume with no INFO_UF2.TXT is not a UF2 bootloader drive") } + @Test + fun `board id tolerates leading whitespace on its line just like the softdevice line`() { + assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(" Board-ID: WisBlock-RAK4631-Board\r\n")) + } + // ── SoftDevice read from the drive: the authoritative gate (R4) ─────────── /** Verbatim `INFO_UF2.TXT` from a stock Seeed Wio Tracker L1 (hwModel 99), captured 2026-07-30. */ From 054d9b01f4da77dccc4752fe1d0f8f40f2cff0aa Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Jul 2026 15:55:30 -0500 Subject: [PATCH 19/19] fix(firmware): stop escaping apostrophes in the new maintenance strings Compose Multiplatform's string-resource loader only strips \n/\t/\uXXXX/\\ -- unlike Android's AAPT, it does not strip \" or \', so a backslash-escaped apostrophe rendered literally in the UI. Caught by the "Check Store Metadata" CI job's check-string-escapes.py guard (added after PR #6357's regression). Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index ac32ba0a993..db882e061e6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -636,23 +636,23 @@ Firmware Edition %1$s has ended. Return to standard Meshtastic firmware to restore normal features. Update firmware - 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 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. Erase and reinstall Erase device - This erases everything on %1$s, including its channels, keys and all settings, then reinstalls the firmware. It cannot be undone and there is no backup.\n\nYou will be asked to select the device\'s drive twice: once for the erase file, then again for the firmware. + This erases everything on %1$s, including its channels, keys and all settings, then reinstalls the firmware. It cannot be undone and there is no backup.\n\nYou will be asked to select the device's drive twice: once for the erase file, then again for the firmware. Erase this device? Select a firmware version first, so the device can be reinstalled after erasing. - That drive isn\'t the device\'s update drive. Select the drive that appears when the device is in update mode (it contains a file named INFO_UF2.TXT). - Select the device\'s update drive - The device reports a different Bluetooth stack than expected, so erasing it isn\'t safe. Please report this along with your device model. - This device\'s bootloader isn\'t one this app has an upgrade for. - This app can\'t confirm which Bluetooth stack this device uses, so erasing it isn\'t safe. Use the web flasher at flasher.meshtastic.org instead. - This device doesn\'t support erasing from the app. + That drive isn't the device's update drive. Select the drive that appears when the device is in update mode (it contains a file named INFO_UF2.TXT). + Select the device's update drive + The device reports a different Bluetooth stack than expected, so erasing it isn't safe. Please report this along with your device model. + This device's bootloader isn't one this app has an upgrade for. + This app can't confirm which Bluetooth stack this device uses, so erasing it isn't safe. Use the web flasher at flasher.meshtastic.org instead. + This device doesn't support erasing from the app. Upgrade bootloader - This installs an upgraded bootloader on %1$s, which makes future Bluetooth updates much faster. The firmware is reinstalled afterwards.\n\nDon\'t disconnect the device while this runs. + This installs an upgraded bootloader on %1$s, which makes future Bluetooth updates much faster. The firmware is reinstalled afterwards.\n\nDon't disconnect the device while this runs. Upgrade bootloader? - That location isn\'t the device\'s update drive, so nothing was written to the device. Select the removable drive that appears when the device is in update mode. + That location isn't the device's update drive, so nothing was written to the device. Select the removable drive that appears when the device is in update mode. The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide. Finish updating %1$s 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. @@ -709,7 +709,7 @@ Open Meshtastic Flasher OTA update failed: %1$s This device's OTA loader rejected the requested update method: %1$s - For RAK WisBlock RAK4631, the vendor\'s bootloader .zip has to be flashed with a serial DFU tool such as adafruit-nrfutil — copying that .zip to the device\'s drive won\'t work. Alternatively, connect this device over USB and use the bootloader upgrade in this app. + For RAK WisBlock RAK4631, the vendor's bootloader .zip has to be flashed with a serial DFU tool such as adafruit-nrfutil — copying that .zip to the device's drive won't work. Alternatively, connect this device over USB and use the bootloader upgrade in this app. Rebooting to DFU... Release Notes %1$s requires a target-matching .bin file.