feat: firmware bootloader ota warnings (#3846)

This commit is contained in:
Mac DeCourcy 2025-11-28 20:17:40 -08:00 committed by GitHub
parent b18ad56113
commit d4a30c0b24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 253 additions and 30 deletions

View file

@ -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"
}
]
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<BootloaderOtaQuirk> = runCatching {
val inputStream = application.assets.open("device_bootloader_ota_quirks.json")
inputStream.use { Json.decodeFromStream<ListWrapper>(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<BootloaderOtaQuirk> = emptyList())
}

View file

@ -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<DeviceHardware?> =
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<DeviceHardware?> = 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<BootloaderOtaQuirk>): Result<DeviceHardware?> =
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<BootloaderOtaQuirk> {
val quirks = bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset()
Timber.d("DeviceHardwareRepository: loaded %d bootloader quirks", quirks.size)
return quirks
}
private fun applyBootloaderQuirk(
hwModel: Int,
base: DeviceHardware?,
quirks: List<BootloaderOtaQuirk>,
): 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)
}

View file

@ -72,6 +72,8 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
requiresBootloaderUpgradeForOta = null,
bootloaderInfoUrl = null,
supportLevel = supportLevel,
tags = tags,
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View file

@ -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<String>? = null,
)

View file

@ -960,6 +960,10 @@
<item quantity="one">Heard %1$d Relay</item>
<item quantity="other">Heard %1$d Relays</item>
</plurals>
<string name="firmware_update_usb_bootloader_warning">%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.</string>
<string name="learn_more">Learn more</string>
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor&apos;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.</string>
<string name="preserve_favorites">Preserve Favorites?</string>
<string name="usb_devices">USB Devices</string>

View file

@ -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,