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
2
.github/workflows/reusable-android-build.yml
vendored
2
.github/workflows/reusable-android-build.yml
vendored
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: meshtastic/Meshtastic-Android
|
||||
files: build/reports/kover/xml/report.xml
|
||||
files: build/reports/kover/report.xml
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
2
.github/workflows/reusable-android-test.yml
vendored
2
.github/workflows/reusable-android-test.yml
vendored
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: meshtastic/Meshtastic-Android
|
||||
files: build/reports/kover/xml/report.xml
|
||||
files: build/reports/kover/report.xml
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ constructor(
|
|||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
deviceId = deviceId.toStringUtf8(),
|
||||
pioEnv = if (myInfo.pioEnv.isNullOrEmpty()) null else myInfo.pioEnv,
|
||||
)
|
||||
}
|
||||
if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
|
||||
|
|
|
|||
|
|
@ -16,23 +16,6 @@
|
|||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.hilt)
|
||||
|
|
@ -61,4 +44,8 @@ dependencies {
|
|||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kermit)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,997 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 32,
|
||||
"identityHash": "9060c828fb1e93ab7316d19dd9989c0f",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "my_node",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "myNodeNum",
|
||||
"columnName": "myNodeNum",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "model",
|
||||
"columnName": "model",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "firmwareVersion",
|
||||
"columnName": "firmwareVersion",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "couldUpdate",
|
||||
"columnName": "couldUpdate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldUpdate",
|
||||
"columnName": "shouldUpdate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "currentPacketId",
|
||||
"columnName": "currentPacketId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "messageTimeoutMsec",
|
||||
"columnName": "messageTimeoutMsec",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minAppVersion",
|
||||
"columnName": "minAppVersion",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxChannels",
|
||||
"columnName": "maxChannels",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasWifi",
|
||||
"columnName": "hasWifi",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deviceId",
|
||||
"columnName": "deviceId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "pioEnv",
|
||||
"columnName": "pioEnv",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"myNodeNum"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "nodes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "num",
|
||||
"columnName": "num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "user",
|
||||
"columnName": "user",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "longName",
|
||||
"columnName": "long_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "shortName",
|
||||
"columnName": "short_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "latitude",
|
||||
"columnName": "latitude",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "longitude",
|
||||
"columnName": "longitude",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snr",
|
||||
"columnName": "snr",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rssi",
|
||||
"columnName": "rssi",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastHeard",
|
||||
"columnName": "last_heard",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deviceTelemetry",
|
||||
"columnName": "device_metrics",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "channel",
|
||||
"columnName": "channel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "viaMqtt",
|
||||
"columnName": "via_mqtt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hopsAway",
|
||||
"columnName": "hops_away",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isFavorite",
|
||||
"columnName": "is_favorite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isIgnored",
|
||||
"columnName": "is_ignored",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isMuted",
|
||||
"columnName": "is_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "environmentTelemetry",
|
||||
"columnName": "environment_metrics",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "powerTelemetry",
|
||||
"columnName": "power_metrics",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "paxcounter",
|
||||
"columnName": "paxcounter",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "publicKey",
|
||||
"columnName": "public_key",
|
||||
"affinity": "BLOB"
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "manuallyVerified",
|
||||
"columnName": "manually_verified",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"num"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_nodes_last_heard",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_heard"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
|
||||
},
|
||||
{
|
||||
"name": "index_nodes_short_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"short_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_nodes_long_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"long_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_nodes_hops_away",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"hops_away"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
|
||||
},
|
||||
{
|
||||
"name": "index_nodes_is_favorite",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"is_favorite"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
|
||||
},
|
||||
{
|
||||
"name": "index_nodes_last_heard_is_favorite",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_heard",
|
||||
"is_favorite"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "packet",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uuid",
|
||||
"columnName": "uuid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "myNodeNum",
|
||||
"columnName": "myNodeNum",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "port_num",
|
||||
"columnName": "port_num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contact_key",
|
||||
"columnName": "contact_key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "received_time",
|
||||
"columnName": "received_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "read",
|
||||
"columnName": "read",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "data",
|
||||
"columnName": "data",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packetId",
|
||||
"columnName": "packet_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "routingError",
|
||||
"columnName": "routing_error",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snr",
|
||||
"columnName": "snr",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rssi",
|
||||
"columnName": "rssi",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hopsAway",
|
||||
"columnName": "hopsAway",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sfpp_hash",
|
||||
"columnName": "sfpp_hash",
|
||||
"affinity": "BLOB"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_packet_myNodeNum",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"myNodeNum"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
|
||||
},
|
||||
{
|
||||
"name": "index_packet_port_num",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"port_num"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
|
||||
},
|
||||
{
|
||||
"name": "index_packet_contact_key",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"contact_key"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
|
||||
},
|
||||
{
|
||||
"name": "index_packet_contact_key_port_num_received_time",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"contact_key",
|
||||
"port_num",
|
||||
"received_time"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
|
||||
},
|
||||
{
|
||||
"name": "index_packet_packet_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"packet_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "contact_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, PRIMARY KEY(`contact_key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "contact_key",
|
||||
"columnName": "contact_key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "muteUntil",
|
||||
"columnName": "muteUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastReadMessageUuid",
|
||||
"columnName": "last_read_message_uuid",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastReadMessageTimestamp",
|
||||
"columnName": "last_read_message_timestamp",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"contact_key"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "log",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uuid",
|
||||
"columnName": "uuid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message_type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "received_date",
|
||||
"columnName": "received_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "raw_message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fromNum",
|
||||
"columnName": "from_num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "portNum",
|
||||
"columnName": "port_num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fromRadio",
|
||||
"columnName": "from_radio",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true,
|
||||
"defaultValue": "x''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_log_from_num",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"from_num"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
|
||||
},
|
||||
{
|
||||
"name": "index_log_port_num",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"port_num"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "quick_chat",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uuid",
|
||||
"columnName": "uuid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "mode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uuid"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "reactions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "myNodeNum",
|
||||
"columnName": "myNodeNum",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "replyId",
|
||||
"columnName": "reply_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emoji",
|
||||
"columnName": "emoji",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snr",
|
||||
"columnName": "snr",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "rssi",
|
||||
"columnName": "rssi",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hopsAway",
|
||||
"columnName": "hopsAway",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "-1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "packetId",
|
||||
"columnName": "packet_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "routingError",
|
||||
"columnName": "routing_error",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retryCount",
|
||||
"columnName": "retry_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "relays",
|
||||
"columnName": "relays",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "relayNode",
|
||||
"columnName": "relay_node",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "to",
|
||||
"columnName": "to",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "channel",
|
||||
"columnName": "channel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sfpp_hash",
|
||||
"columnName": "sfpp_hash",
|
||||
"affinity": "BLOB"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"myNodeNum",
|
||||
"reply_id",
|
||||
"user_id",
|
||||
"emoji"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_reactions_reply_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"reply_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_reactions_packet_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"packet_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "num",
|
||||
"columnName": "num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "proto",
|
||||
"columnName": "proto",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"num"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_metadata_num",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"num"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "device_hardware",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "activelySupported",
|
||||
"columnName": "actively_supported",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "architecture",
|
||||
"columnName": "architecture",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "display_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasInkHud",
|
||||
"columnName": "has_ink_hud",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasMui",
|
||||
"columnName": "has_mui",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hwModel",
|
||||
"columnName": "hwModel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hwModelSlug",
|
||||
"columnName": "hw_model_slug",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "images",
|
||||
"columnName": "images",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partitionScheme",
|
||||
"columnName": "partition_scheme",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "platformioTarget",
|
||||
"columnName": "platformio_target",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "requiresDfu",
|
||||
"columnName": "requires_dfu",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportLevel",
|
||||
"columnName": "support_level",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"platformio_target"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "firmware_release",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pageUrl",
|
||||
"columnName": "page_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releaseNotes",
|
||||
"columnName": "release_notes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "zipUrl",
|
||||
"columnName": "zip_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releaseType",
|
||||
"columnName": "release_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "traceroute_node_position",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "logUuid",
|
||||
"columnName": "log_uuid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "requestId",
|
||||
"columnName": "request_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nodeNum",
|
||||
"columnName": "node_num",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"log_uuid",
|
||||
"node_num"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_traceroute_node_position_log_uuid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"log_uuid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
|
||||
},
|
||||
{
|
||||
"name": "index_traceroute_node_position_request_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"request_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "log",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"log_uuid"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uuid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9060c828fb1e93ab7316d19dd9989c0f')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -89,8 +89,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
|||
AutoMigration(from = 28, to = 29),
|
||||
AutoMigration(from = 29, to = 30, spec = AutoMigration29to30::class),
|
||||
AutoMigration(from = 30, to = 31),
|
||||
AutoMigration(from = 31, to = 32),
|
||||
],
|
||||
version = 31,
|
||||
version = 32,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
|
|
@ -28,8 +27,17 @@ interface DeviceHardwareDao {
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(deviceHardware: DeviceHardwareEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
|
||||
|
||||
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
|
||||
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity?
|
||||
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>
|
||||
|
||||
@Query("SELECT * FROM device_hardware WHERE platformio_target = :target")
|
||||
suspend fun getByTarget(target: String): DeviceHardwareEntity?
|
||||
|
||||
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target")
|
||||
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity?
|
||||
|
||||
@Query("DELETE FROM device_hardware")
|
||||
suspend fun deleteAll()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
|
|
@ -32,12 +31,12 @@ data class DeviceHardwareEntity(
|
|||
@ColumnInfo(name = "display_name") val displayName: String,
|
||||
@ColumnInfo(name = "has_ink_hud") val hasInkHud: Boolean? = null,
|
||||
@ColumnInfo(name = "has_mui") val hasMui: Boolean? = null,
|
||||
@PrimaryKey val hwModel: Int,
|
||||
val hwModel: Int,
|
||||
@ColumnInfo(name = "hw_model_slug") val hwModelSlug: String,
|
||||
val images: List<String>?,
|
||||
@ColumnInfo(name = "last_updated") val lastUpdated: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "partition_scheme") val partitionScheme: String? = null,
|
||||
@ColumnInfo(name = "platformio_target") val platformioTarget: String,
|
||||
@PrimaryKey @ColumnInfo(name = "platformio_target") val platformioTarget: String,
|
||||
@ColumnInfo(name = "requires_dfu") val requiresDfu: Boolean?,
|
||||
@ColumnInfo(name = "support_level") val supportLevel: Int?,
|
||||
val tags: List<String>?,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.database.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
|
|
@ -34,6 +33,7 @@ data class MyNodeEntity(
|
|||
val maxChannels: Int,
|
||||
val hasWifi: Boolean,
|
||||
val deviceId: String? = "unknown",
|
||||
val pioEnv: String? = null,
|
||||
) {
|
||||
/** A human readable description of the software/hardware version */
|
||||
val firmwareString: String
|
||||
|
|
@ -54,5 +54,6 @@ data class MyNodeEntity(
|
|||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = deviceId,
|
||||
pioEnv = pioEnv,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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 android.os.Parcelable
|
||||
|
|
@ -37,6 +36,7 @@ data class MyNodeInfo(
|
|||
val channelUtilization: Float,
|
||||
val airUtilTx: Float,
|
||||
val deviceId: String?,
|
||||
val pioEnv: String? = null,
|
||||
) : Parcelable {
|
||||
/** A human readable description of the software/hardware version */
|
||||
val firmwareString: String
|
||||
|
|
|
|||
|
|
@ -53,7 +53,11 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
|
|||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): File? {
|
||||
if (hardware.supportsUnifiedOta) {
|
||||
// Try MCU-generic Unified OTA binary first, as it's the fastest and newest standard.
|
||||
// However, we skip the generic binary for devices with specialized UI requirements (MUI/TFT/E-Ink)
|
||||
// because the generic binary often lacks the necessary drivers.
|
||||
val hasSpecificUi = hardware.hasMui == true || hardware.hasInkHud == true
|
||||
if (hardware.supportsUnifiedOta && !hasSpecificUi) {
|
||||
val mcu = hardware.architecture.replace("-", "")
|
||||
val otaFilename = "mt-$mcu-ota.bin"
|
||||
retrieve(
|
||||
|
|
@ -69,6 +73,7 @@ class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFil
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback to board-specific binary using the now-accurate platformioTarget.
|
||||
return retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
|||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.datastore.BootloaderWarningDataSource
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
|
|
@ -152,7 +153,7 @@ constructor(
|
|||
viewModelScope.launch {
|
||||
_state.value = FirmwareUpdateState.Checking
|
||||
runCatching {
|
||||
val ourNode = nodeRepository.ourNodeInfo.value
|
||||
val ourNode = nodeRepository.myNodeInfo.value
|
||||
val address = radioPrefs.devAddr?.drop(1)
|
||||
if (address == null || ourNode == null) {
|
||||
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
|
||||
|
|
@ -160,7 +161,7 @@ constructor(
|
|||
}
|
||||
getDeviceHardware(ourNode)?.let { deviceHardware ->
|
||||
_deviceHardware.value = deviceHardware
|
||||
_currentFirmwareVersion.value = ourNode.metadata?.firmwareVersion
|
||||
_currentFirmwareVersion.value = ourNode.firmwareVersion
|
||||
|
||||
val releaseFlow =
|
||||
if (_selectedReleaseType.value == FirmwareReleaseType.LOCAL) {
|
||||
|
|
@ -192,7 +193,7 @@ constructor(
|
|||
!dismissed &&
|
||||
radioPrefs.isBle(),
|
||||
updateMethod = firmwareUpdateMethod,
|
||||
currentFirmwareVersion = ourNode.metadata?.firmwareVersion,
|
||||
currentFirmwareVersion = ourNode.firmwareVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -455,12 +456,15 @@ constructor(
|
|||
return !isBatteryLow
|
||||
}
|
||||
|
||||
private suspend fun getDeviceHardware(ourNode: org.meshtastic.core.database.model.Node): DeviceHardware? {
|
||||
val hwModel = ourNode.user.hwModel?.number
|
||||
return if (hwModel != null) {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrElse {
|
||||
private suspend fun getDeviceHardware(ourNode: MyNodeEntity): DeviceHardware? {
|
||||
val nodeInfo = nodeRepository.ourNodeInfo.value
|
||||
val hwModelInt = nodeInfo?.user?.hwModel?.number
|
||||
val target = ourNode.pioEnv
|
||||
|
||||
return if (hwModelInt != null) {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(hwModelInt, target).getOrElse {
|
||||
_state.value =
|
||||
FirmwareUpdateState.Error(getString(Res.string.firmware_update_unknown_hardware, hwModel))
|
||||
FirmwareUpdateState.Error(getString(Res.string.firmware_update_unknown_hardware, hwModelInt))
|
||||
null
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class FirmwareRetrieverTest {
|
|||
private val retriever = FirmwareRetriever(fileHandler)
|
||||
|
||||
@Test
|
||||
fun `retrieveEsp32Firmware uses mt-arch-ota bin when Unified OTA is supported`() = runTest {
|
||||
fun `retrieveEsp32Firmware uses mt-arch-ota bin when Unified OTA is supported and no screen`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
|
|
@ -40,6 +40,8 @@ class FirmwareRetrieverTest {
|
|||
platformioTarget = "heltec-v3",
|
||||
architecture = "esp32-s3",
|
||||
supportsUnifiedOta = true,
|
||||
hasMui = false,
|
||||
hasInkHud = false,
|
||||
)
|
||||
val expectedFile = File("mt-esp32s3-ota.bin")
|
||||
|
||||
|
|
@ -53,10 +55,32 @@ class FirmwareRetrieverTest {
|
|||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin",
|
||||
)
|
||||
fileHandler.downloadFile(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin",
|
||||
"mt-esp32s3-ota.bin",
|
||||
any(),
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveEsp32Firmware skips mt-arch-ota bin for devices with MUI`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "T_DECK",
|
||||
platformioTarget = "tdeck-tft",
|
||||
architecture = "esp32-s3",
|
||||
supportsUnifiedOta = true,
|
||||
hasMui = true,
|
||||
)
|
||||
val expectedFile = File("firmware-tdeck-tft-2.5.0.bin")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveEsp32Firmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify(exactly = 0) { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) }
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tdeck-tft-2.5.0.bin",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -70,15 +94,16 @@ class FirmwareRetrieverTest {
|
|||
platformioTarget = "heltec-v3",
|
||||
architecture = "esp32-s3",
|
||||
supportsUnifiedOta = true,
|
||||
hasMui = false,
|
||||
)
|
||||
val expectedFile = File("firmware-heltec-v3-2.5.0.bin")
|
||||
|
||||
// First check for mt-esp32s3-ota.bin fails
|
||||
// Generic fast OTA check fails
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) } returns false
|
||||
// ZIP download fails too for the OTA attempt to reach second retrieve call
|
||||
coEvery { fileHandler.downloadFile(any(), "firmware_release.zip", any()) } returns null
|
||||
|
||||
// Second check for board-specific bin succeeds
|
||||
// Board-specific check succeeds
|
||||
coEvery { fileHandler.checkUrlExists(match { it.contains("firmware-heltec-v3") }) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), "firmware-heltec-v3-2.5.0.bin", any()) } returns expectedFile
|
||||
coEvery { fileHandler.extractFirmware(any<File>(), any(), any(), any()) } returns null
|
||||
|
|
@ -118,11 +143,6 @@ class FirmwareRetrieverTest {
|
|||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin",
|
||||
)
|
||||
fileHandler.downloadFile(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin",
|
||||
"firmware-tlora-v2-2.5.0.bin",
|
||||
any(),
|
||||
)
|
||||
}
|
||||
// Verify we DID NOT check for mt-esp32-ota.bin
|
||||
coVerify(exactly = 0) { fileHandler.checkUrlExists(match { it.contains("mt-esp32-ota.bin") }) }
|
||||
|
|
@ -153,6 +173,50 @@ class FirmwareRetrieverTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveOtaFirmware uses platformioTarget for NRF52 variant`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
|
||||
val hardware =
|
||||
DeviceHardware(
|
||||
hwModelSlug = "RAK4631",
|
||||
platformioTarget = "rak4631_nomadstar_meteor_pro",
|
||||
architecture = "nrf52840",
|
||||
)
|
||||
val expectedFile = File("firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveOtaFirmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-rak4631_nomadstar_meteor_pro-2.5.0-ota.zip",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveOtaFirmware uses correct filename for STM32`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/stm32.zip")
|
||||
val hardware =
|
||||
DeviceHardware(hwModelSlug = "ST_GENERIC", platformioTarget = "stm32-generic", architecture = "stm32")
|
||||
val expectedFile = File("firmware-stm32-generic-2.5.0-ota.zip")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveOtaFirmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-stm32-generic-2.5.0-ota.zip",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveUsbFirmware uses correct uf2 extension for RP2040`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/rp2040.zip")
|
||||
|
|
@ -167,7 +231,27 @@ class FirmwareRetrieverTest {
|
|||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-pico-2.5.0.uf2",
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/" +
|
||||
"firmware-2.5.0/firmware-pico-2.5.0.uf2",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieveUsbFirmware uses correct uf2 extension for NRF52`() = runTest {
|
||||
val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip")
|
||||
val hardware = DeviceHardware(hwModelSlug = "T_ECHO", platformioTarget = "t-echo", architecture = "nrf52840")
|
||||
val expectedFile = File("firmware-t-echo-2.5.0.uf2")
|
||||
|
||||
coEvery { fileHandler.checkUrlExists(any()) } returns true
|
||||
coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile
|
||||
|
||||
val result = retriever.retrieveUsbFirmware(release, hardware) {}
|
||||
|
||||
assertEquals(expectedFile, result)
|
||||
coVerify {
|
||||
fileHandler.checkUrlExists(
|
||||
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-t-echo-2.5.0.uf2",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,13 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
|
|||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
InsetDivider()
|
||||
|
||||
val deviceText =
|
||||
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
|
||||
?: deviceHardware.displayName
|
||||
ListItem(
|
||||
text = stringResource(Res.string.hardware),
|
||||
leadingIcon = Icons.Default.Router,
|
||||
supportingText = deviceHardware.displayName,
|
||||
supportingText = deviceText,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.feature.node.metrics
|
||||
|
||||
import android.app.Application
|
||||
|
|
@ -33,7 +32,6 @@ import kotlinx.coroutines.flow.combine
|
|||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -215,21 +213,25 @@ constructor(
|
|||
viewModelScope.launch {
|
||||
if (currentDestNum != null) {
|
||||
launch {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { nodes -> nodes[currentDestNum] to nodes.keys.firstOrNull() }
|
||||
combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { nodes, myInfo ->
|
||||
nodes[currentDestNum] to (nodes.keys.firstOrNull() to myInfo)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (node, ourNode) ->
|
||||
.collect { (node, localData) ->
|
||||
val (ourNodeNum, myInfo) = localData
|
||||
// Create a fallback node if not found in database (for hidden clients, etc.)
|
||||
val actualNode = node ?: createFallbackNode(currentDestNum)
|
||||
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
|
||||
val deviceHardware =
|
||||
actualNode.user.hwModel.safeNumber().let {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(it)
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(it, target = pioEnv)
|
||||
}
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
node = actualNode,
|
||||
isLocal = currentDestNum == ourNode,
|
||||
isLocal = currentDestNum == ourNodeNum,
|
||||
deviceHardware = deviceHardware.getOrNull(),
|
||||
reportedTarget = pioEnv,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* 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
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* 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.feature.node.model
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -56,6 +55,8 @@ data class MetricsState(
|
|||
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
|
||||
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
|
||||
val paxMetrics: List<MeshLog> = emptyList(),
|
||||
/** The PlatformIO environment reported by the device (if known). */
|
||||
val reportedTarget: String? = null,
|
||||
) {
|
||||
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue