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/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/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/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/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..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 @@ -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,35 @@ 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 +234,13 @@ 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 - } - .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } } - .getOrNull() - .orEmpty() + // 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 applyBootloaderQuirk( hwModel: Int, 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/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..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 @@ -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/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 1d8f12204dc..5b6ef64617c 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 @@ -35,9 +38,15 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.di.PROCESS_LIFECYCLE +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 @@ -92,6 +101,40 @@ 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..db882e061e6 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. @@ -692,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/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 2a151d8aea5..d7ede7ff257 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 @@ -55,6 +55,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.di.PROCESS_LIFECYCLE +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 @@ -164,6 +165,7 @@ class SharedRadioInterfaceService( private val radioPrefs: RadioPrefs, private val transportFactory: RadioTransportFactory, private val analytics: PlatformAnalytics, + private val firmwareMaintenanceLock: FirmwareMaintenanceLock, ) : RadioInterfaceService { override val supportedDeviceTypes: List @@ -475,7 +477,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) { @@ -491,7 +493,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) { @@ -531,6 +533,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/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..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 @@ -146,6 +146,15 @@ actual fun rememberSaveFileLauncher( } } +@Composable +actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + onTreeUriSelect(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..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 @@ -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(onTreeUriSelect: (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..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,6 +46,8 @@ actual fun rememberSaveFileLauncher( @Composable actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> } +@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (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..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 @@ -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(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) { + onTreeUriSelect(CommonUri.parse(chooser.selectedFile.toURI().toString())) + } else { + onTreeUriSelect(null) + } +} + /** JVM — Reads text from a file URI. */ @Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> 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 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/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/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 6ec2bb46497..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 @@ -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,83 @@ 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 + // 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) + } + .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 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/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt index efe43d6ad8e..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 @@ -26,11 +26,49 @@ 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 + + @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 = + 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/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index fafa48104eb..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 @@ -70,6 +70,45 @@ 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 + + /** + * 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. + * + * 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/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/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 6a3ae777148..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" @@ -40,14 +43,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 +67,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, @@ -76,6 +79,61 @@ 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. + */ + @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) + } 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(HEX_RADIX)}, " + + "expected ${expectedAddress.toString(HEX_RADIX)} — 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. @@ -89,7 +147,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..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 @@ -94,6 +94,14 @@ 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_select_drive +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_recovery_button import org.meshtastic.core.resources.firmware_recovery_explanation import org.meshtastic.core.resources.firmware_update_almost_there @@ -155,6 +163,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 +197,12 @@ 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 +213,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, @@ -370,6 +388,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 } @@ -410,7 +433,12 @@ 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, + ) } } } @@ -475,6 +503,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), @@ -718,6 +756,91 @@ 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 inline next to the disabled button. + * + * 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 = + usbMaintenanceRefusalMessage(refusal).asString() + @Composable private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) { val openUrl = rememberOpenUrl() @@ -869,8 +992,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 +1013,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 +1036,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..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 @@ -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,25 @@ 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..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 @@ -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 @@ -62,6 +63,9 @@ 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_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 +85,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 +116,8 @@ class FirmwareUpdateViewModel( private val firmwareUpdateManager: FirmwareUpdateManager, 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() { @@ -147,6 +154,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 { @@ -160,6 +179,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. @@ -254,6 +278,12 @@ class FirmwareUpdateViewModel( radioPrefs.isBle(), updateMethod = firmwareUpdateMethod, currentFirmwareVersion = ourNode.firmwareVersion, + maintenance = + usbMaintenanceGate( + hardware = deviceHardware, + updateMethod = firmwareUpdateMethod, + hasRelease = release != null, + ), ) } } @@ -460,9 +490,169 @@ class FirmwareUpdateViewModel( } } + // ── USB maintenance (factory erase / bootloader upgrade) ──────────────────────────────────────── + + fun startFactoryErase() = startUsbMaintenance(UsbMaintenanceRequest.FactoryErase) + + 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 + + // 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(usbMaintenanceRefusalMessage(it)) + return + } + + UsbMaintenanceRequest.BootloaderUpgrade -> + if (!gate.showBootloaderUpgrade) { + _state.value = + FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(UsbMaintenanceRefusal.UnknownBoardId)) + return + } + } + + 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 + 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)) + } finally { + // Preparation that produced no passes never reached the device; hand the transport back. + if (pendingUsbPasses.isEmpty()) firmwareMaintenanceLock.release() + } + } + } + } + + /** + * 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. + */ + @Suppress("ReturnCount") // preconditions guarding a destructive write + 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, usbMaintenanceRefusalMessage(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) { + // Sequence complete: hand the device back before verifying, so the normal reconnect can run. + firmwareMaintenanceLock.release() + 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) { + 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( + 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) }, + ) + 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 { @@ -483,6 +673,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/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt index d102ed4e48c..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 @@ -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/MaintenanceUf2.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt new file mode 100644 index 00000000000..2e3c624f468 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt @@ -0,0 +1,378 @@ +/* + * 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", + ) + +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 the **Board-ID the device itself reports**. + * + * 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`. + * + * 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_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. + * + * 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", + ) + +/** + * 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 +} + +/** + * 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.trimStart().startsWith(UF2_BOARD_ID_PREFIX, ignoreCase = true) } + ?.substringAfter(':') + ?.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. + */ +@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 + + 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 + +/** Byte offset of `targetAddr` within a UF2 block header. */ +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 + return readLittleEndianUInt32(bytes, UF2_TARGET_ADDR_OFFSET) +} + +private fun readLittleEndianUInt32(bytes: ByteArray, offset: Int): Long { + var value = 0L + 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 new file mode 100644 index 00000000000..e3260421760 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt @@ -0,0 +1,253 @@ +/* + * 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 +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. + * + * 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. */ +@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. + */ + 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, + + /** 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, +} + +/** + * 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. + * + * 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. 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), + ) +} + +/** + * 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. + */ +@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) + + 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/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt index d5402777201..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 @@ -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,291 @@ 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 (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 + 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 + } + + /** 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. */ +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/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 { 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..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 @@ -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,22 @@ 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 + + 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/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..199ce2523bb --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt @@ -0,0 +1,206 @@ +/* + * 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 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 = + 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/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..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 @@ -68,6 +69,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 +115,8 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, 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 52f34a1b9a7..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 @@ -76,6 +77,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 +138,8 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, + firmwareRetriever, + FirmwareMaintenanceLock(), TestApplicationCoroutineScope(testDispatcher), hiddenFeaturesUnlock, ) 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/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..2babb9c94ba --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt @@ -0,0 +1,397 @@ +/* + * 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 supported targets`() { + assertTrue( + usbMaintenanceGate(nrf(target = "rak4631"), FirmwareUpdateMethod.Usb, hasRelease = true) + .showBootloaderUpgrade, + ) + assertTrue( + usbMaintenanceGate(nrf(target = "heltec-mesh-node-t114"), FirmwareUpdateMethod.Usb, hasRelease = true) + .showBootloaderUpgrade, + "T114 is on OTAFIX's supported list even though the project names differ", + ) + } + + @Test + 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 rather than the build target`() { + val rak = otafixUf2ForBoardId("WisBlock-RAK4631-Board") + val techo = otafixUf2ForBoardId("nRF52840-TEcho-v1") + + assertNotNull(rak) + assertNotNull(techo) + assertTrue(rak.fileName.contains("wiscore_rak4631_board")) + 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 `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") + } + + @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. */ + 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)!!)) + } + + /** 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" + + /** + * 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. + 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") + 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 + 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/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/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 8eb951938c3..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 @@ -205,6 +205,40 @@ 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 } + + /** + * 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, 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..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 @@ -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 @@ -38,12 +39,14 @@ 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 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 @@ -82,6 +85,8 @@ 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 val firmwareMaintenanceLock = FirmwareMaintenanceLock() private lateinit var viewModel: FirmwareUpdateViewModel @@ -135,6 +140,8 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, + firmwareRetriever, + firmwareMaintenanceLock, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), ) @@ -713,4 +720,199 @@ 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 `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") + 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, + ) + + @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") + } + + @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", + ) + } } 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()