mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: firmware bootloader ota warnings (#3846)
This commit is contained in:
parent
b18ad56113
commit
d4a30c0b24
8 changed files with 253 additions and 30 deletions
24
app/src/main/assets/device_bootloader_ota_quirks.json
Normal file
24
app/src/main/assets/device_bootloader_ota_quirks.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
|
|||
partitionScheme = partitionScheme,
|
||||
platformioTarget = platformioTarget,
|
||||
requiresDfu = requiresDfu,
|
||||
requiresBootloaderUpgradeForOta = null,
|
||||
bootloaderInfoUrl = null,
|
||||
supportLevel = supportLevel,
|
||||
tags = tags,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue