mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(firmware): Use pio_env to select correct firmware variant (#4244)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
4a65292bcf
commit
75a3f89f51
19 changed files with 1426 additions and 152 deletions
|
|
@ -33,12 +33,17 @@ constructor(
|
|||
) {
|
||||
private val deviceHardwareDao by lazy { deviceHardwareDaoLazy.get() }
|
||||
|
||||
suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) = withContext(dispatchers.io) {
|
||||
deviceHardware.forEach { deviceHardware -> deviceHardwareDao.insert(deviceHardware.asEntity()) }
|
||||
}
|
||||
suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) =
|
||||
withContext(dispatchers.io) { deviceHardwareDao.insertAll(deviceHardware.map { it.asEntity() }) }
|
||||
|
||||
suspend fun deleteAllDeviceHardware() = withContext(dispatchers.io) { deviceHardwareDao.deleteAll() }
|
||||
|
||||
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity? =
|
||||
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity> =
|
||||
withContext(dispatchers.io) { deviceHardwareDao.getByHwModel(hwModel) }
|
||||
|
||||
suspend fun getByTarget(target: String): DeviceHardwareEntity? =
|
||||
withContext(dispatchers.io) { deviceHardwareDao.getByTarget(target) }
|
||||
|
||||
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? =
|
||||
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ constructor(
|
|||
) {
|
||||
|
||||
/**
|
||||
* Retrieves device hardware information by its model ID.
|
||||
* Retrieves device hardware information by its model ID and optional target string.
|
||||
*
|
||||
* This function implements a cache-aside pattern with a fallback mechanism:
|
||||
* 1. Check for a valid, non-expired local cache entry.
|
||||
|
|
@ -53,97 +53,151 @@ constructor(
|
|||
* 4. If the cache is empty, fall back to loading data from a bundled JSON asset.
|
||||
*
|
||||
* @param hwModel The hardware model identifier.
|
||||
* @param target Optional PlatformIO target environment name to disambiguate multiple variants.
|
||||
* @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) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel, forceRefresh=$forceRefresh)"
|
||||
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
|
||||
suspend fun getDeviceHardwareByModel(
|
||||
hwModel: Int,
|
||||
target: String? = null,
|
||||
forceRefresh: Boolean = false,
|
||||
): Result<DeviceHardware?> = withContext(dispatchers.io) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
|
||||
" target=$target, forceRefresh=$forceRefresh)"
|
||||
}
|
||||
|
||||
val quirks = loadQuirks()
|
||||
|
||||
if (forceRefresh) {
|
||||
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
|
||||
localDataSource.deleteAllDeviceHardware()
|
||||
} else {
|
||||
// 1. Attempt to retrieve from cache first
|
||||
var cachedEntities = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
// Fallback to target-only lookup if hwModel-based lookup yielded nothing
|
||||
if (cachedEntities.isEmpty() && target != null) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
|
||||
}
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) {
|
||||
cachedEntities = listOf(byTarget)
|
||||
}
|
||||
}
|
||||
|
||||
val quirks = loadQuirks()
|
||||
if (cachedEntities.isNotEmpty() && cachedEntities.all { !it.isStale() }) {
|
||||
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
|
||||
val matched = disambiguate(cachedEntities, target)
|
||||
return@withContext Result.success(
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
|
||||
)
|
||||
}
|
||||
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
|
||||
}
|
||||
|
||||
if (forceRefresh) {
|
||||
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
|
||||
localDataSource.deleteAllDeviceHardware()
|
||||
} else {
|
||||
// 1. Attempt to retrieve from cache first
|
||||
val cachedEntity = localDataSource.getByHwModel(hwModel)
|
||||
if (cachedEntity != null && !cachedEntity.isStale()) {
|
||||
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
|
||||
// 2. Fetch from remote API
|
||||
runCatching {
|
||||
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
|
||||
val remoteHardware = remoteDataSource.getAllDeviceHardware()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
|
||||
}
|
||||
|
||||
localDataSource.insertAllDeviceHardware(remoteHardware)
|
||||
var fromDb = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
// Fallback to target lookup after remote fetch
|
||||
if (fromDb.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) fromDb = listOf(byTarget)
|
||||
}
|
||||
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel returned" +
|
||||
" ${fromDb.size} entries"
|
||||
}
|
||||
disambiguate(fromDb, target)?.asExternalModel()
|
||||
}
|
||||
.onSuccess {
|
||||
// Successfully fetched and found the model
|
||||
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks, target))
|
||||
}
|
||||
.onFailure { e ->
|
||||
Logger.w(e) {
|
||||
"DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
|
||||
}
|
||||
|
||||
// 3. Attempt to use stale cache as a fallback, but only if it looks complete.
|
||||
var staleEntities = localDataSource.getByHwModel(hwModel)
|
||||
if (staleEntities.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) staleEntities = listOf(byTarget)
|
||||
}
|
||||
|
||||
if (staleEntities.isNotEmpty() && staleEntities.all { !it.isIncomplete() }) {
|
||||
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
|
||||
val matched = disambiguate(staleEntities, target)
|
||||
return@withContext Result.success(
|
||||
applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
|
||||
)
|
||||
}
|
||||
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
|
||||
}
|
||||
|
||||
// 2. Fetch from remote API
|
||||
runCatching {
|
||||
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
|
||||
val remoteHardware = remoteDataSource.getAllDeviceHardware()
|
||||
// 4. Fallback to bundled JSON if cache is empty or incomplete
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
|
||||
"DeviceHardwareRepository: cache ${if (staleEntities.isEmpty()) "empty" else "incomplete"} " +
|
||||
"for hwModel=$hwModel, falling back to bundled JSON asset"
|
||||
}
|
||||
|
||||
localDataSource.insertAllDeviceHardware(remoteHardware)
|
||||
val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel ${if (fromDb != null) "succeeded" else "returned null"}"
|
||||
}
|
||||
fromDb
|
||||
return@withContext loadFromBundledJson(hwModel, target, quirks)
|
||||
}
|
||||
.onSuccess {
|
||||
// Successfully fetched and found the model
|
||||
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks))
|
||||
}
|
||||
.onFailure { e ->
|
||||
Logger.w(e) {
|
||||
"DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attempt to use stale cache as a fallback, but only if it looks complete.
|
||||
val staleEntity = localDataSource.getByHwModel(hwModel)
|
||||
if (staleEntity != null && !staleEntity.isIncomplete()) {
|
||||
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
|
||||
return@withContext Result.success(
|
||||
applyBootloaderQuirk(hwModel, staleEntity.asExternalModel(), quirks),
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback to bundled JSON if cache is empty or incomplete
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: cache ${if (staleEntity == null) "empty" else "incomplete"} for hwModel=$hwModel, falling back to bundled JSON asset"
|
||||
}
|
||||
return@withContext loadFromBundledJson(hwModel, quirks)
|
||||
}
|
||||
private suspend fun loadFromBundledJson(
|
||||
hwModel: Int,
|
||||
target: String?,
|
||||
quirks: List<BootloaderOtaQuirk>,
|
||||
): Result<DeviceHardware?> = runCatching {
|
||||
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
|
||||
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
|
||||
}
|
||||
|
||||
private suspend fun loadFromBundledJson(hwModel: Int, quirks: List<BootloaderOtaQuirk>): Result<DeviceHardware?> =
|
||||
runCatching {
|
||||
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
|
||||
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
|
||||
}
|
||||
localDataSource.insertAllDeviceHardware(jsonHardware)
|
||||
var baseList = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
localDataSource.insertAllDeviceHardware(jsonHardware)
|
||||
val base = localDataSource.getByHwModel(hwModel)?.asExternalModel()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel ${if (base != null) "succeeded" else "returned null"}"
|
||||
}
|
||||
|
||||
applyBootloaderQuirk(hwModel, base, quirks)
|
||||
// Fallback to target lookup after JSON load
|
||||
if (baseList.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) baseList = listOf(byTarget)
|
||||
}
|
||||
.also { result ->
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
Logger.e(e) {
|
||||
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
|
||||
}
|
||||
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel returned ${baseList.size} entries"
|
||||
}
|
||||
|
||||
val matched = disambiguate(baseList, target)
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target)
|
||||
}
|
||||
.also { result ->
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
Logger.e(e) {
|
||||
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? = when {
|
||||
entities.isEmpty() -> null
|
||||
target == null -> entities.first()
|
||||
else -> {
|
||||
entities.find { it.platformioTarget == target }
|
||||
?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
|
||||
?: entities.first()
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the cached entity is missing important fields and should be refreshed. */
|
||||
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
|
||||
|
|
@ -168,22 +222,33 @@ constructor(
|
|||
hwModel: Int,
|
||||
base: DeviceHardware?,
|
||||
quirks: List<BootloaderOtaQuirk>,
|
||||
reportedTarget: String? = null,
|
||||
): DeviceHardware? {
|
||||
if (base == null) return null
|
||||
|
||||
val quirk = quirks.firstOrNull { it.hwModel == hwModel }
|
||||
Logger.d { "DeviceHardwareRepository: applyBootloaderQuirk for hwModel=$hwModel, quirk found=${quirk != null}" }
|
||||
return if (quirk != null) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=${quirk.requiresBootloaderUpgradeForOta}, infoUrl=${quirk.infoUrl}"
|
||||
val matchedQuirk = quirks.firstOrNull { it.hwModel == hwModel }
|
||||
val result =
|
||||
if (matchedQuirk != null) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: applying quirk: " +
|
||||
"requiresBootloaderUpgradeForOta=${matchedQuirk.requiresBootloaderUpgradeForOta}, " +
|
||||
"infoUrl=${matchedQuirk.infoUrl}"
|
||||
}
|
||||
base.copy(
|
||||
requiresBootloaderUpgradeForOta = matchedQuirk.requiresBootloaderUpgradeForOta,
|
||||
supportsUnifiedOta = matchedQuirk.supportsUnifiedOta,
|
||||
bootloaderInfoUrl = matchedQuirk.infoUrl,
|
||||
)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base.copy(
|
||||
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
|
||||
supportsUnifiedOta = quirk.supportsUnifiedOta,
|
||||
bootloaderInfoUrl = quirk.infoUrl,
|
||||
)
|
||||
|
||||
// If the device reported a specific build environment via pio_env, trust it for firmware retrieval
|
||||
return if (reportedTarget != null) {
|
||||
Logger.d { "DeviceHardwareRepository: using reported target $reportedTarget for hardware info" }
|
||||
result.copy(platformioTarget = reportedTarget)
|
||||
} else {
|
||||
base
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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.repository
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
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.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
|
||||
|
||||
class DeviceHardwareRepositoryTest {
|
||||
|
||||
private val remoteDataSource: DeviceHardwareRemoteDataSource = mockk()
|
||||
private val localDataSource: DeviceHardwareLocalDataSource = mockk()
|
||||
private val jsonDataSource: DeviceHardwareJsonDataSource = mockk()
|
||||
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource = mockk()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
|
||||
|
||||
private val repository =
|
||||
DeviceHardwareRepository(
|
||||
remoteDataSource,
|
||||
localDataSource,
|
||||
jsonDataSource,
|
||||
bootloaderOtaQuirksJsonDataSource,
|
||||
dispatchers,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getDeviceHardwareByModel uses target for disambiguation`() = runTest(testDispatcher) {
|
||||
val hwModel = 50 // T_DECK
|
||||
val target = "tdeck-pro"
|
||||
val entities =
|
||||
listOf(createEntity(hwModel, "t-deck", "T-Deck"), createEntity(hwModel, "tdeck-pro", "T-Deck Pro"))
|
||||
|
||||
coEvery { localDataSource.getByHwModel(hwModel) } returns entities
|
||||
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
|
||||
|
||||
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
|
||||
|
||||
assertEquals("T-Deck Pro", result?.displayName)
|
||||
assertEquals("tdeck-pro", result?.platformioTarget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeviceHardwareByModel falls back to first entity when target not found`() = runTest(testDispatcher) {
|
||||
val hwModel = 50
|
||||
val target = "unknown-variant"
|
||||
val entities =
|
||||
listOf(createEntity(hwModel, "t-deck", "T-Deck"), createEntity(hwModel, "t-deck-tft", "T-Deck TFT"))
|
||||
|
||||
coEvery { localDataSource.getByHwModel(hwModel) } returns entities
|
||||
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
|
||||
|
||||
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
|
||||
|
||||
// Should fall back to first entity if no exact match
|
||||
assertEquals("T-Deck", result?.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeviceHardwareByModel falls back to target lookup when hwModel not found`() = runTest(testDispatcher) {
|
||||
val hwModel = 0 // Unknown
|
||||
val target = "tdeck-pro"
|
||||
val entity = createEntity(102, "tdeck-pro", "T-Deck Pro")
|
||||
|
||||
coEvery { localDataSource.getByHwModel(hwModel) } returns emptyList()
|
||||
coEvery { localDataSource.getByTarget(target) } returns entity
|
||||
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
|
||||
|
||||
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
|
||||
|
||||
assertEquals("T-Deck Pro", result?.displayName)
|
||||
assertEquals("tdeck-pro", result?.platformioTarget)
|
||||
}
|
||||
|
||||
private fun createEntity(hwModel: Int, target: String, displayName: String) = DeviceHardwareEntity(
|
||||
activelySupported = true,
|
||||
architecture = "esp32-s3",
|
||||
displayName = displayName,
|
||||
hwModel = hwModel,
|
||||
hwModelSlug = "T_DECK",
|
||||
images = listOf("image.svg"), // MUST be non-empty to avoid being considered incomplete/stale
|
||||
platformioTarget = target,
|
||||
requiresDfu = false,
|
||||
supportLevel = 0,
|
||||
tags = emptyList(),
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue