Skip to content

Commit d4a30c0

Browse files
authored
feat: firmware bootloader ota warnings (#3846)
1 parent b18ad56 commit d4a30c0

File tree

8 files changed

+253
-30
lines changed

8 files changed

+253
-30
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"devices": [
3+
{
4+
"hwModel": 18,
5+
"hwModelSlug": "NANO_G2_ULTRA",
6+
"requiresBootloaderUpgradeForOta": true,
7+
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
8+
},
9+
{
10+
"hwModel": 9,
11+
"hwModelSlug": "RAK4631",
12+
"requiresBootloaderUpgradeForOta": true,
13+
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
14+
},
15+
{
16+
"hwModel": 96,
17+
"hwModelSlug": "NOMADSTAR_METEOR_PRO",
18+
"requiresBootloaderUpgradeForOta": true,
19+
"infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader"
20+
}
21+
]
22+
}
23+
24+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.core.data.datasource
19+
20+
import android.app.Application
21+
import kotlinx.serialization.ExperimentalSerializationApi
22+
import kotlinx.serialization.Serializable
23+
import kotlinx.serialization.json.Json
24+
import kotlinx.serialization.json.decodeFromStream
25+
import org.meshtastic.core.model.BootloaderOtaQuirk
26+
import timber.log.Timber
27+
import javax.inject.Inject
28+
29+
class BootloaderOtaQuirksJsonDataSource @Inject constructor(private val application: Application) {
30+
@OptIn(ExperimentalSerializationApi::class)
31+
fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = runCatching {
32+
val inputStream = application.assets.open("device_bootloader_ota_quirks.json")
33+
inputStream.use { Json.decodeFromStream<ListWrapper>(it).devices }
34+
}
35+
.onFailure { e -> Timber.w(e, "Failed to load device_bootloader_ota_quirks.json") }
36+
.getOrDefault(emptyList())
37+
38+
@Serializable private data class ListWrapper(val devices: List<BootloaderOtaQuirk> = emptyList())
39+
}

core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ package org.meshtastic.core.data.repository
1919

2020
import kotlinx.coroutines.Dispatchers
2121
import kotlinx.coroutines.withContext
22+
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
2223
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
2324
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
2425
import org.meshtastic.core.database.entity.DeviceHardwareEntity
2526
import org.meshtastic.core.database.entity.asExternalModel
27+
import org.meshtastic.core.model.BootloaderOtaQuirk
2628
import org.meshtastic.core.model.DeviceHardware
2729
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
2830
import timber.log.Timber
@@ -38,6 +40,7 @@ constructor(
3840
private val remoteDataSource: DeviceHardwareRemoteDataSource,
3941
private val localDataSource: DeviceHardwareLocalDataSource,
4042
private val jsonDataSource: DeviceHardwareJsonDataSource,
43+
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
4144
) {
4245

4346
/**
@@ -53,6 +56,7 @@ constructor(
5356
* @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
5457
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
5558
*/
59+
@Suppress("LongMethod")
5660
suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result<DeviceHardware?> =
5761
withContext(Dispatchers.IO) {
5862
Timber.d(
@@ -61,6 +65,8 @@ constructor(
6165
forceRefresh,
6266
)
6367

68+
val quirks = loadQuirks()
69+
6470
if (forceRefresh) {
6571
Timber.d("DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache")
6672
localDataSource.deleteAllDeviceHardware()
@@ -69,7 +75,9 @@ constructor(
6975
val cachedEntity = localDataSource.getByHwModel(hwModel)
7076
if (cachedEntity != null && !cachedEntity.isStale()) {
7177
Timber.d("DeviceHardwareRepository: using fresh cached device hardware for hwModel=%d", hwModel)
72-
return@withContext Result.success(cachedEntity.asExternalModel())
78+
return@withContext Result.success(
79+
applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
80+
)
7381
}
7482
Timber.d("DeviceHardwareRepository: no fresh cache for hwModel=%d, attempting remote fetch", hwModel)
7583
}
@@ -94,7 +102,7 @@ constructor(
94102
}
95103
.onSuccess {
96104
// Successfully fetched and found the model
97-
return@withContext Result.success(it)
105+
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks))
98106
}
99107
.onFailure { e ->
100108
Timber.w(
@@ -107,7 +115,9 @@ constructor(
107115
val staleEntity = localDataSource.getByHwModel(hwModel)
108116
if (staleEntity != null && !staleEntity.isIncomplete()) {
109117
Timber.d("DeviceHardwareRepository: using stale cached device hardware for hwModel=%d", hwModel)
110-
return@withContext Result.success(staleEntity.asExternalModel())
118+
return@withContext Result.success(
119+
applyBootloaderQuirk(hwModel, staleEntity.asExternalModel(), quirks),
120+
)
111121
}
112122

113123
// 4. Fallback to bundled JSON if cache is empty or incomplete
@@ -116,36 +126,38 @@ constructor(
116126
if (staleEntity == null) "empty" else "incomplete",
117127
hwModel,
118128
)
119-
return@withContext loadFromBundledJson(hwModel)
129+
return@withContext loadFromBundledJson(hwModel, quirks)
120130
}
121131
}
122132

123-
private suspend fun loadFromBundledJson(hwModel: Int): Result<DeviceHardware?> = runCatching {
124-
Timber.d("DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=%d", hwModel)
125-
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
126-
Timber.d(
127-
"DeviceHardwareRepository: bundled JSON returned %d device hardware entries",
128-
jsonHardware.size,
129-
)
133+
private suspend fun loadFromBundledJson(hwModel: Int, quirks: List<BootloaderOtaQuirk>): Result<DeviceHardware?> =
134+
runCatching {
135+
Timber.d("DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=%d", hwModel)
136+
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
137+
Timber.d(
138+
"DeviceHardwareRepository: bundled JSON returned %d device hardware entries",
139+
jsonHardware.size,
140+
)
130141

131-
localDataSource.insertAllDeviceHardware(jsonHardware)
132-
val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel()
133-
Timber.d(
134-
"DeviceHardwareRepository: lookup after JSON load for hwModel=%d %s",
135-
hwModel,
136-
if (fromDb != null) "succeeded" else "returned null",
137-
)
138-
fromDb
139-
}
140-
.also { result ->
141-
result.exceptionOrNull()?.let { e ->
142-
Timber.e(
143-
e,
144-
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=%d",
145-
hwModel,
146-
)
147-
}
142+
localDataSource.insertAllDeviceHardware(jsonHardware)
143+
val base = localDataSource.getByHwModel(hwModel)?.asExternalModel()
144+
Timber.d(
145+
"DeviceHardwareRepository: lookup after JSON load for hwModel=%d %s",
146+
hwModel,
147+
if (base != null) "succeeded" else "returned null",
148+
)
149+
150+
applyBootloaderQuirk(hwModel, base, quirks)
148151
}
152+
.also { result ->
153+
result.exceptionOrNull()?.let { e ->
154+
Timber.e(
155+
e,
156+
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=%d",
157+
hwModel,
158+
)
159+
}
160+
}
149161

150162
/** Returns true if the cached entity is missing important fields and should be refreshed. */
151163
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
@@ -160,6 +172,40 @@ constructor(
160172
private fun DeviceHardwareEntity.isStale(): Boolean =
161173
isIncomplete() || (System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
162174

175+
private fun loadQuirks(): List<BootloaderOtaQuirk> {
176+
val quirks = bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset()
177+
Timber.d("DeviceHardwareRepository: loaded %d bootloader quirks", quirks.size)
178+
return quirks
179+
}
180+
181+
private fun applyBootloaderQuirk(
182+
hwModel: Int,
183+
base: DeviceHardware?,
184+
quirks: List<BootloaderOtaQuirk>,
185+
): DeviceHardware? {
186+
if (base == null) return null
187+
188+
val quirk = quirks.firstOrNull { it.hwModel == hwModel }
189+
Timber.d(
190+
"DeviceHardwareRepository: applyBootloaderQuirk for hwModel=%d, quirk found=%b",
191+
hwModel,
192+
quirk != null,
193+
)
194+
return if (quirk != null) {
195+
Timber.d(
196+
"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=%b, infoUrl=%s",
197+
quirk.requiresBootloaderUpgradeForOta,
198+
quirk.infoUrl,
199+
)
200+
base.copy(
201+
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
202+
bootloaderInfoUrl = quirk.infoUrl,
203+
)
204+
} else {
205+
base
206+
}
207+
}
208+
163209
companion object {
164210
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.DAYS.toMillis(1)
165211
}

core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
7272
partitionScheme = partitionScheme,
7373
platformioTarget = platformioTarget,
7474
requiresDfu = requiresDfu,
75+
requiresBootloaderUpgradeForOta = null,
76+
bootloaderInfoUrl = null,
7577
supportLevel = supportLevel,
7678
tags = tags,
7779
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.core.model
19+
20+
import kotlinx.serialization.SerialName
21+
import kotlinx.serialization.Serializable
22+
23+
@Serializable
24+
data class BootloaderOtaQuirk(
25+
/** Hardware model id, matches DeviceHardware.hwModel. */
26+
@SerialName("hwModel") val hwModel: Int,
27+
/** Optional slug for readability / tooling. */
28+
@SerialName("hwModelSlug") val hwModelSlug: String? = null,
29+
/**
30+
* Indicates that devices usually ship with a bootloader that does not support OTA out of the box and require a
31+
* one-time bootloader upgrade (typically via USB) before DFU updates from the app work.
32+
*/
33+
@SerialName("requiresBootloaderUpgradeForOta") val requiresBootloaderUpgradeForOta: Boolean = false,
34+
/** Optional URL pointing to documentation on how to update the bootloader. */
35+
@SerialName("infoUrl") val infoUrl: String? = null,
36+
)

core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ data class DeviceHardware(
3232
val partitionScheme: String? = null,
3333
val platformioTarget: String = "",
3434
val requiresDfu: Boolean? = null,
35+
/**
36+
* Indicates that the device typically ships with a bootloader that does not support OTA DFU, and that a one-time
37+
* bootloader upgrade (usually over USB) is recommended before attempting firmware updates from the app.
38+
*/
39+
val requiresBootloaderUpgradeForOta: Boolean? = null,
40+
/** Optional URL pointing to documentation for upgrading the bootloader. */
41+
val bootloaderInfoUrl: String? = null,
3542
val supportLevel: Int? = null,
3643
val tags: List<String>? = null,
3744
)

core/strings/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,10 @@
960960
<item quantity="one">Heard %1$d Relay</item>
961961
<item quantity="other">Heard %1$d Relays</item>
962962
</plurals>
963+
964+
<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>
965+
<string name="learn_more">Learn more</string>
966+
<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>
963967
<string name="preserve_favorites">Preserve Favorites?</string>
964968
<string name="usb_devices">USB Devices</string>
965969

0 commit comments

Comments
 (0)