diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml
index d39f29eda2..e15945a779 100644
--- a/.github/workflows/reusable-android-build.yml
+++ b/.github/workflows/reusable-android-build.yml
@@ -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() }}
diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml
index d4266ab792..3fb655ddce 100644
--- a/.github/workflows/reusable-android-test.yml
+++ b/.github/workflows/reusable-android-test.yml
@@ -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() }}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
index 9f58c6e795..af43059285 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -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()) {
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index ecc20f4b04..b2397489ed 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -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 .
- */
-
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)
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
index 13d61526ad..852c56e04e 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
@@ -33,12 +33,17 @@ constructor(
) {
private val deviceHardwareDao by lazy { deviceHardwareDaoLazy.get() }
- suspend fun insertAllDeviceHardware(deviceHardware: List) = withContext(dispatchers.io) {
- deviceHardware.forEach { deviceHardware -> deviceHardwareDao.insert(deviceHardware.asEntity()) }
- }
+ suspend fun insertAllDeviceHardware(deviceHardware: List) =
+ 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 =
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) }
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
index 9ab83d4c48..95b5abe1de 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
@@ -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 =
- 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 = withContext(dispatchers.io) {
+ Logger.d {
+ "DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
+ " target=$target, forceRefresh=$forceRefresh)"
+ }
- val quirks = loadQuirks()
+ 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
- val cachedEntity = localDataSource.getByHwModel(hwModel)
- if (cachedEntity != null && !cachedEntity.isStale()) {
- Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
- return@withContext Result.success(
- applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
- )
- }
- 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
+ var cachedEntities = localDataSource.getByHwModel(hwModel)
- // 2. Fetch from remote API
- runCatching {
- Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
- val remoteHardware = remoteDataSource.getAllDeviceHardware()
+ // Fallback to target-only lookup if hwModel-based lookup yielded nothing
+ if (cachedEntities.isEmpty() && target != null) {
Logger.d {
- "DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
+ "DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
}
-
- 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"}"
+ val byTarget = localDataSource.getByTarget(target)
+ if (byTarget != null) {
+ cachedEntities = listOf(byTarget)
}
- fromDb
}
- .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)
- }
+
+ 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" }
}
- private suspend fun loadFromBundledJson(hwModel: Int, quirks: List): Result =
+ // 2. Fetch from remote API
runCatching {
- Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
- val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
+ Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
+ val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
- "DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
+ "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)
}
- 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"}"
+ "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)
+ }
- applyBootloaderQuirk(hwModel, base, quirks)
+ 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, matched?.asExternalModel(), quirks, target),
+ )
+ }
+
+ // 4. Fallback to bundled JSON if cache is empty or incomplete
+ Logger.d {
+ "DeviceHardwareRepository: cache ${if (staleEntities.isEmpty()) "empty" else "incomplete"} " +
+ "for hwModel=$hwModel, falling back to bundled JSON asset"
+ }
+ return@withContext loadFromBundledJson(hwModel, target, quirks)
+ }
+ }
+
+ private suspend fun loadFromBundledJson(
+ hwModel: Int,
+ target: String?,
+ quirks: List,
+ ): Result = 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)
+
+ // 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, 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,
+ 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
}
}
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt
new file mode 100644
index 0000000000..b4afe99e36
--- /dev/null
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt
@@ -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 .
+ */
+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(),
+ )
+}
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/32.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/32.json
new file mode 100644
index 0000000000..39e7356d32
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/32.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 67d60e30ea..4aecd9c537 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -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)
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index 8138e7c429..5d6b4ea94f 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -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 .
*/
-
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)
+
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
- suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity?
+ suspend fun getByHwModel(hwModel: Int): List
+
+ @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()
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
index b4416ed9a8..3e72720295 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
@@ -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 .
*/
-
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?,
@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?,
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
index c351fdf897..2dcbac1a9f 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
@@ -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 .
*/
-
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,
)
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
index 3cabeeb6fe..aaab77ebc2 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
@@ -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 .
*/
-
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
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
index 86dbbf3119..d9b51a7ca1 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
@@ -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,
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index c07a9adf8c..d57d8999ef 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -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 {
diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt
index 2bd944b86f..432bd79c2e 100644
--- a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt
+++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt
@@ -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(), 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",
)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
index cc9233d0ea..0ce76dc876 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
@@ -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,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 5e0713f002..215f12ff5f 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -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 .
*/
-
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,
)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
index ef7adab00c..47962376c0 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
@@ -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 .
*/
-
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 = emptyList(),
+ /** The PlatformIO environment reported by the device (if known). */
+ val reportedTarget: String? = null,
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()