Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/src/main/assets/device_bootloader_ota_quirks.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}


Original file line number Diff line number Diff line change
@@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +40,7 @@ constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
) {

/**
Expand All @@ -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(
Expand All @@ -61,6 +65,8 @@ constructor(
forceRefresh,
)

val quirks = loadQuirks()

if (forceRefresh) {
Timber.d("DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache")
localDataSource.deleteAllDeviceHardware()
Expand All @@ -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)
}
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -116,24 +126,26 @@ 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
}
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)
}

/** Returns true if the cached entity is missing important fields and should be refreshed. */
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
Expand All @@ -148,6 +160,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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
requiresBootloaderUpgradeForOta = null,
bootloaderInfoUrl = null,
supportLevel = supportLevel,
tags = tags,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,17 @@ 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
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
Expand Down Expand Up @@ -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(
Expand All @@ -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))

Expand Down Expand Up @@ -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,
Expand Down