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:
James Rich 2026-01-16 10:51:55 -06:00 committed by GitHub
parent 4a65292bcf
commit 75a3f89f51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1426 additions and 152 deletions

View file

@ -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) }
}

View file

@ -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
}
}

View file

@ -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(),
)
}