From d4a30c0b24712b3e3972e1602a16e04356a18be2 Mon Sep 17 00:00:00 2001
From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com>
Date: Fri, 28 Nov 2025 20:17:40 -0800
Subject: [PATCH] feat: firmware bootloader ota warnings (#3846)
---
.../assets/device_bootloader_ota_quirks.json | 24 +++++
.../BootloaderOtaQuirksJsonDataSource.kt | 39 +++++++
.../repository/DeviceHardwareRepository.kt | 102 +++++++++++++-----
.../database/entity/DeviceHardwareEntity.kt | 2 +
.../core/model/BootloaderOtaQuirk.kt | 36 +++++++
.../meshtastic/core/model/DeviceHardware.kt | 7 ++
.../composeResources/values/strings.xml | 4 +
.../feature/firmware/FirmwareUpdateScreen.kt | 69 +++++++++++-
8 files changed, 253 insertions(+), 30 deletions(-)
create mode 100644 app/src/main/assets/device_bootloader_ota_quirks.json
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
diff --git a/app/src/main/assets/device_bootloader_ota_quirks.json b/app/src/main/assets/device_bootloader_ota_quirks.json
new file mode 100644
index 000000000..92b156834
--- /dev/null
+++ b/app/src/main/assets/device_bootloader_ota_quirks.json
@@ -0,0 +1,24 @@
+{
+ "devices": [
+ {
+ "hwModel": 18,
+ "hwModelSlug": "NANO_G2_ULTRA",
+ "requiresBootloaderUpgradeForOta": true,
+ "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
+ },
+ {
+ "hwModel": 9,
+ "hwModelSlug": "RAK4631",
+ "requiresBootloaderUpgradeForOta": true,
+ "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
+ },
+ {
+ "hwModel": 96,
+ "hwModelSlug": "NOMADSTAR_METEOR_PRO",
+ "requiresBootloaderUpgradeForOta": true,
+ "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
+ }
+ ]
+}
+
+
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
new file mode 100644
index 000000000..5cc5081ba
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 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.data.datasource
+
+import android.app.Application
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import org.meshtastic.core.model.BootloaderOtaQuirk
+import timber.log.Timber
+import javax.inject.Inject
+
+class BootloaderOtaQuirksJsonDataSource @Inject constructor(private val application: Application) {
+ @OptIn(ExperimentalSerializationApi::class)
+ fun loadBootloaderOtaQuirksFromJsonAsset(): List = runCatching {
+ val inputStream = application.assets.open("device_bootloader_ota_quirks.json")
+ inputStream.use { Json.decodeFromStream(it).devices }
+ }
+ .onFailure { e -> Timber.w(e, "Failed to load device_bootloader_ota_quirks.json") }
+ .getOrDefault(emptyList())
+
+ @Serializable private data class ListWrapper(val devices: List = emptyList())
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
index ec113ad86..fcd0b30cf 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
@@ -19,10 +19,12 @@ package org.meshtastic.core.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asExternalModel
+import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import timber.log.Timber
@@ -38,6 +40,7 @@ constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
+ private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
) {
/**
@@ -53,6 +56,7 @@ constructor(
* @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
+ @Suppress("LongMethod")
suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result =
withContext(Dispatchers.IO) {
Timber.d(
@@ -61,6 +65,8 @@ constructor(
forceRefresh,
)
+ val quirks = loadQuirks()
+
if (forceRefresh) {
Timber.d("DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache")
localDataSource.deleteAllDeviceHardware()
@@ -69,7 +75,9 @@ constructor(
val cachedEntity = localDataSource.getByHwModel(hwModel)
if (cachedEntity != null && !cachedEntity.isStale()) {
Timber.d("DeviceHardwareRepository: using fresh cached device hardware for hwModel=%d", hwModel)
- return@withContext Result.success(cachedEntity.asExternalModel())
+ return@withContext Result.success(
+ applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
+ )
}
Timber.d("DeviceHardwareRepository: no fresh cache for hwModel=%d, attempting remote fetch", hwModel)
}
@@ -94,7 +102,7 @@ constructor(
}
.onSuccess {
// Successfully fetched and found the model
- return@withContext Result.success(it)
+ return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks))
}
.onFailure { e ->
Timber.w(
@@ -107,7 +115,9 @@ constructor(
val staleEntity = localDataSource.getByHwModel(hwModel)
if (staleEntity != null && !staleEntity.isIncomplete()) {
Timber.d("DeviceHardwareRepository: using stale cached device hardware for hwModel=%d", hwModel)
- return@withContext Result.success(staleEntity.asExternalModel())
+ return@withContext Result.success(
+ applyBootloaderQuirk(hwModel, staleEntity.asExternalModel(), quirks),
+ )
}
// 4. Fallback to bundled JSON if cache is empty or incomplete
@@ -116,36 +126,38 @@ constructor(
if (staleEntity == null) "empty" else "incomplete",
hwModel,
)
- return@withContext loadFromBundledJson(hwModel)
+ return@withContext loadFromBundledJson(hwModel, quirks)
}
}
- private suspend fun loadFromBundledJson(hwModel: Int): Result = runCatching {
- Timber.d("DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=%d", hwModel)
- val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
- Timber.d(
- "DeviceHardwareRepository: bundled JSON returned %d device hardware entries",
- jsonHardware.size,
- )
+ private suspend fun loadFromBundledJson(hwModel: Int, quirks: List): Result =
+ runCatching {
+ Timber.d("DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=%d", hwModel)
+ val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
+ Timber.d(
+ "DeviceHardwareRepository: bundled JSON returned %d device hardware entries",
+ jsonHardware.size,
+ )
- localDataSource.insertAllDeviceHardware(jsonHardware)
- val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel()
- Timber.d(
- "DeviceHardwareRepository: lookup after JSON load for hwModel=%d %s",
- hwModel,
- if (fromDb != null) "succeeded" else "returned null",
- )
- fromDb
- }
- .also { result ->
- result.exceptionOrNull()?.let { e ->
- Timber.e(
- e,
- "DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=%d",
- hwModel,
- )
- }
+ localDataSource.insertAllDeviceHardware(jsonHardware)
+ val base = localDataSource.getByHwModel(hwModel)?.asExternalModel()
+ Timber.d(
+ "DeviceHardwareRepository: lookup after JSON load for hwModel=%d %s",
+ hwModel,
+ if (base != null) "succeeded" else "returned null",
+ )
+
+ applyBootloaderQuirk(hwModel, base, quirks)
}
+ .also { result ->
+ result.exceptionOrNull()?.let { e ->
+ Timber.e(
+ e,
+ "DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=%d",
+ hwModel,
+ )
+ }
+ }
/** Returns true if the cached entity is missing important fields and should be refreshed. */
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
@@ -160,6 +172,40 @@ constructor(
private fun DeviceHardwareEntity.isStale(): Boolean =
isIncomplete() || (System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
+ private fun loadQuirks(): List {
+ val quirks = bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset()
+ Timber.d("DeviceHardwareRepository: loaded %d bootloader quirks", quirks.size)
+ return quirks
+ }
+
+ private fun applyBootloaderQuirk(
+ hwModel: Int,
+ base: DeviceHardware?,
+ quirks: List,
+ ): DeviceHardware? {
+ if (base == null) return null
+
+ val quirk = quirks.firstOrNull { it.hwModel == hwModel }
+ Timber.d(
+ "DeviceHardwareRepository: applyBootloaderQuirk for hwModel=%d, quirk found=%b",
+ hwModel,
+ quirk != null,
+ )
+ return if (quirk != null) {
+ Timber.d(
+ "DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=%b, infoUrl=%s",
+ quirk.requiresBootloaderUpgradeForOta,
+ quirk.infoUrl,
+ )
+ base.copy(
+ requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
+ bootloaderInfoUrl = quirk.infoUrl,
+ )
+ } else {
+ base
+ }
+ }
+
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.DAYS.toMillis(1)
}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
index f250507d9..b4416ed9a 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
@@ -72,6 +72,8 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
+ requiresBootloaderUpgradeForOta = null,
+ bootloaderInfoUrl = null,
supportLevel = supportLevel,
tags = tags,
)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
new file mode 100644
index 000000000..d793a3414
--- /dev/null
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2025 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.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BootloaderOtaQuirk(
+ /** Hardware model id, matches DeviceHardware.hwModel. */
+ @SerialName("hwModel") val hwModel: Int,
+ /** Optional slug for readability / tooling. */
+ @SerialName("hwModelSlug") val hwModelSlug: String? = null,
+ /**
+ * Indicates that devices usually ship with a bootloader that does not support OTA out of the box and require a
+ * one-time bootloader upgrade (typically via USB) before DFU updates from the app work.
+ */
+ @SerialName("requiresBootloaderUpgradeForOta") val requiresBootloaderUpgradeForOta: Boolean = false,
+ /** Optional URL pointing to documentation on how to update the bootloader. */
+ @SerialName("infoUrl") val infoUrl: String? = null,
+)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt
index 31dec4fa5..c5b7fa253 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt
@@ -32,6 +32,13 @@ data class DeviceHardware(
val partitionScheme: String? = null,
val platformioTarget: String = "",
val requiresDfu: Boolean? = null,
+ /**
+ * Indicates that the device typically ships with a bootloader that does not support OTA DFU, and that a one-time
+ * bootloader upgrade (usually over USB) is recommended before attempting firmware updates from the app.
+ */
+ val requiresBootloaderUpgradeForOta: Boolean? = null,
+ /** Optional URL pointing to documentation for upgrading the bootloader. */
+ val bootloaderInfoUrl: String? = null,
val supportLevel: Int? = null,
val tags: List? = null,
)
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 8b21c934e..89c3e7f1b 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -960,6 +960,10 @@
- Heard %1$d Relay
- Heard %1$d Relays
+
+ %1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.
+ Learn more
+ 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.
Preserve Favorites?
USB Devices
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
index cf0496596..34b741446 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
@@ -118,6 +118,7 @@ import org.meshtastic.core.strings.firmware_update_error
import org.meshtastic.core.strings.firmware_update_hang_tight
import org.meshtastic.core.strings.firmware_update_keep_device_close
import org.meshtastic.core.strings.firmware_update_latest
+import org.meshtastic.core.strings.firmware_update_rak4631_bootloader_hint
import org.meshtastic.core.strings.firmware_update_retry
import org.meshtastic.core.strings.firmware_update_select_file
import org.meshtastic.core.strings.firmware_update_stable
@@ -125,7 +126,9 @@ import org.meshtastic.core.strings.firmware_update_success
import org.meshtastic.core.strings.firmware_update_taking_a_while
import org.meshtastic.core.strings.firmware_update_title
import org.meshtastic.core.strings.firmware_update_unknown_release
+import org.meshtastic.core.strings.firmware_update_usb_bootloader_warning
import org.meshtastic.core.strings.i_know_what_i_m_doing
+import org.meshtastic.core.strings.learn_more
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -257,6 +260,7 @@ private fun ColumnScope.ReadyState(
) {
var showDisclaimer by remember { mutableStateOf(false) }
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ val device = state.deviceHardware
if (showDisclaimer) {
DisclaimerDialog(
@@ -268,10 +272,15 @@ private fun ColumnScope.ReadyState(
)
}
- DeviceHardwareImage(state.deviceHardware, Modifier.size(150.dp))
+ DeviceHardwareImage(device, Modifier.size(150.dp))
Spacer(Modifier.height(24.dp))
- DeviceInfoCard(state.deviceHardware, state.release)
+ DeviceInfoCard(device, state.release)
+
+ if (device.requiresBootloaderUpgradeForOta == true) {
+ Spacer(Modifier.height(16.dp))
+ BootloaderWarningCard(device)
+ }
Spacer(Modifier.height(24.dp))
@@ -442,6 +451,62 @@ private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRele
}
}
+@Composable
+private fun BootloaderWarningCard(deviceHardware: DeviceHardware) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text =
+ stringResource(Res.string.firmware_update_usb_bootloader_warning, deviceHardware.displayName),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ val slug = deviceHardware.hwModelSlug
+ if (slug.equals("RAK4631", ignoreCase = true)) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = stringResource(Res.string.firmware_update_rak4631_bootloader_hint),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ val infoUrl = deviceHardware.bootloaderInfoUrl
+ if (!infoUrl.isNullOrEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ val context = LocalContext.current
+ TextButton(
+ onClick = {
+ runCatching {
+ val intent =
+ android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
+ data = android.net.Uri.parse(infoUrl)
+ }
+ context.startActivity(intent)
+ }
+ },
+ ) {
+ Text(text = stringResource(Res.string.learn_more))
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun ReleaseTypeSelector(
selectedReleaseType: FirmwareReleaseType,