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,