feat(firmware): Use pio_env to select correct firmware variant (#4244)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-16 10:51:55 -06:00 committed by GitHub
parent 4a65292bcf
commit 75a3f89f51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1426 additions and 152 deletions

View file

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

View file

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

View file

@ -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()) {

View file

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

View file

@ -33,12 +33,17 @@ constructor(
) {
private val deviceHardwareDao by lazy { deviceHardwareDaoLazy.get() }
suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) = withContext(dispatchers.io) {
deviceHardware.forEach { deviceHardware -> deviceHardwareDao.insert(deviceHardware.asEntity()) }
}
suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) =
withContext(dispatchers.io) { deviceHardwareDao.insertAll(deviceHardware.map { it.asEntity() }) }
suspend fun deleteAllDeviceHardware() = withContext(dispatchers.io) { deviceHardwareDao.deleteAll() }
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity? =
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity> =
withContext(dispatchers.io) { deviceHardwareDao.getByHwModel(hwModel) }
suspend fun getByTarget(target: String): DeviceHardwareEntity? =
withContext(dispatchers.io) { deviceHardwareDao.getByTarget(target) }
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? =
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
}

View file

@ -44,7 +44,7 @@ constructor(
) {
/**
* Retrieves device hardware information by its model ID.
* Retrieves device hardware information by its model ID and optional target string.
*
* This function implements a cache-aside pattern with a fallback mechanism:
* 1. Check for a valid, non-expired local cache entry.
@ -53,97 +53,151 @@ constructor(
* 4. If the cache is empty, fall back to loading data from a bundled JSON asset.
*
* @param hwModel The hardware model identifier.
* @param target Optional PlatformIO target environment name to disambiguate multiple variants.
* @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
@Suppress("LongMethod")
suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result<DeviceHardware?> =
withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel, forceRefresh=$forceRefresh)"
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
suspend fun getDeviceHardwareByModel(
hwModel: Int,
target: String? = null,
forceRefresh: Boolean = false,
): Result<DeviceHardware?> = withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
" target=$target, forceRefresh=$forceRefresh)"
}
val quirks = loadQuirks()
if (forceRefresh) {
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
localDataSource.deleteAllDeviceHardware()
} else {
// 1. Attempt to retrieve from cache first
var cachedEntities = localDataSource.getByHwModel(hwModel)
// Fallback to target-only lookup if hwModel-based lookup yielded nothing
if (cachedEntities.isEmpty() && target != null) {
Logger.d {
"DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
}
val byTarget = localDataSource.getByTarget(target)
if (byTarget != null) {
cachedEntities = listOf(byTarget)
}
}
val quirks = loadQuirks()
if (cachedEntities.isNotEmpty() && cachedEntities.all { !it.isStale() }) {
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
val matched = disambiguate(cachedEntities, target)
return@withContext Result.success(
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
)
}
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
}
if (forceRefresh) {
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
localDataSource.deleteAllDeviceHardware()
} else {
// 1. Attempt to retrieve from cache first
val cachedEntity = localDataSource.getByHwModel(hwModel)
if (cachedEntity != null && !cachedEntity.isStale()) {
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
// 2. Fetch from remote API
runCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
}
localDataSource.insertAllDeviceHardware(remoteHardware)
var fromDb = localDataSource.getByHwModel(hwModel)
// Fallback to target lookup after remote fetch
if (fromDb.isEmpty() && target != null) {
val byTarget = localDataSource.getByTarget(target)
if (byTarget != null) fromDb = listOf(byTarget)
}
Logger.d {
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel returned" +
" ${fromDb.size} entries"
}
disambiguate(fromDb, target)?.asExternalModel()
}
.onSuccess {
// Successfully fetched and found the model
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks, target))
}
.onFailure { e ->
Logger.w(e) {
"DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
}
// 3. Attempt to use stale cache as a fallback, but only if it looks complete.
var staleEntities = localDataSource.getByHwModel(hwModel)
if (staleEntities.isEmpty() && target != null) {
val byTarget = localDataSource.getByTarget(target)
if (byTarget != null) staleEntities = listOf(byTarget)
}
if (staleEntities.isNotEmpty() && staleEntities.all { !it.isIncomplete() }) {
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
val matched = disambiguate(staleEntities, target)
return@withContext Result.success(
applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
)
}
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
}
// 2. Fetch from remote API
runCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
// 4. Fallback to bundled JSON if cache is empty or incomplete
Logger.d {
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
"DeviceHardwareRepository: cache ${if (staleEntities.isEmpty()) "empty" else "incomplete"} " +
"for hwModel=$hwModel, falling back to bundled JSON asset"
}
localDataSource.insertAllDeviceHardware(remoteHardware)
val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel()
Logger.d {
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel ${if (fromDb != null) "succeeded" else "returned null"}"
}
fromDb
return@withContext loadFromBundledJson(hwModel, target, quirks)
}
.onSuccess {
// Successfully fetched and found the model
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks))
}
.onFailure { e ->
Logger.w(e) {
"DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
}
}
// 3. Attempt to use stale cache as a fallback, but only if it looks complete.
val staleEntity = localDataSource.getByHwModel(hwModel)
if (staleEntity != null && !staleEntity.isIncomplete()) {
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
return@withContext Result.success(
applyBootloaderQuirk(hwModel, staleEntity.asExternalModel(), quirks),
)
}
// 4. Fallback to bundled JSON if cache is empty or incomplete
Logger.d {
"DeviceHardwareRepository: cache ${if (staleEntity == null) "empty" else "incomplete"} for hwModel=$hwModel, falling back to bundled JSON asset"
}
return@withContext loadFromBundledJson(hwModel, quirks)
}
private suspend fun loadFromBundledJson(
hwModel: Int,
target: String?,
quirks: List<BootloaderOtaQuirk>,
): Result<DeviceHardware?> = runCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
}
private suspend fun loadFromBundledJson(hwModel: Int, quirks: List<BootloaderOtaQuirk>): Result<DeviceHardware?> =
runCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
}
localDataSource.insertAllDeviceHardware(jsonHardware)
var baseList = localDataSource.getByHwModel(hwModel)
localDataSource.insertAllDeviceHardware(jsonHardware)
val base = localDataSource.getByHwModel(hwModel)?.asExternalModel()
Logger.d {
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel ${if (base != null) "succeeded" else "returned null"}"
}
applyBootloaderQuirk(hwModel, base, quirks)
// Fallback to target lookup after JSON load
if (baseList.isEmpty() && target != null) {
val byTarget = localDataSource.getByTarget(target)
if (byTarget != null) baseList = listOf(byTarget)
}
.also { result ->
result.exceptionOrNull()?.let { e ->
Logger.e(e) {
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
}
Logger.d {
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel returned ${baseList.size} entries"
}
val matched = disambiguate(baseList, target)
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target)
}
.also { result ->
result.exceptionOrNull()?.let { e ->
Logger.e(e) {
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
}
}
}
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? = when {
entities.isEmpty() -> null
target == null -> entities.first()
else -> {
entities.find { it.platformioTarget == target }
?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
?: entities.first()
}
}
/** Returns true if the cached entity is missing important fields and should be refreshed. */
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
@ -168,22 +222,33 @@ constructor(
hwModel: Int,
base: DeviceHardware?,
quirks: List<BootloaderOtaQuirk>,
reportedTarget: String? = null,
): DeviceHardware? {
if (base == null) return null
val quirk = quirks.firstOrNull { it.hwModel == hwModel }
Logger.d { "DeviceHardwareRepository: applyBootloaderQuirk for hwModel=$hwModel, quirk found=${quirk != null}" }
return if (quirk != null) {
Logger.d {
"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=${quirk.requiresBootloaderUpgradeForOta}, infoUrl=${quirk.infoUrl}"
val matchedQuirk = quirks.firstOrNull { it.hwModel == hwModel }
val result =
if (matchedQuirk != null) {
Logger.d {
"DeviceHardwareRepository: applying quirk: " +
"requiresBootloaderUpgradeForOta=${matchedQuirk.requiresBootloaderUpgradeForOta}, " +
"infoUrl=${matchedQuirk.infoUrl}"
}
base.copy(
requiresBootloaderUpgradeForOta = matchedQuirk.requiresBootloaderUpgradeForOta,
supportsUnifiedOta = matchedQuirk.supportsUnifiedOta,
bootloaderInfoUrl = matchedQuirk.infoUrl,
)
} else {
base
}
base.copy(
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
supportsUnifiedOta = quirk.supportsUnifiedOta,
bootloaderInfoUrl = quirk.infoUrl,
)
// If the device reported a specific build environment via pio_env, trust it for firmware retrieval
return if (reportedTarget != null) {
Logger.d { "DeviceHardwareRepository: using reported target $reportedTarget for hardware info" }
result.copy(platformioTarget = reportedTarget)
} else {
base
result
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
class DeviceHardwareRepositoryTest {
private val remoteDataSource: DeviceHardwareRemoteDataSource = mockk()
private val localDataSource: DeviceHardwareLocalDataSource = mockk()
private val jsonDataSource: DeviceHardwareJsonDataSource = mockk()
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource = mockk()
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val repository =
DeviceHardwareRepository(
remoteDataSource,
localDataSource,
jsonDataSource,
bootloaderOtaQuirksJsonDataSource,
dispatchers,
)
@Test
fun `getDeviceHardwareByModel uses target for disambiguation`() = runTest(testDispatcher) {
val hwModel = 50 // T_DECK
val target = "tdeck-pro"
val entities =
listOf(createEntity(hwModel, "t-deck", "T-Deck"), createEntity(hwModel, "tdeck-pro", "T-Deck Pro"))
coEvery { localDataSource.getByHwModel(hwModel) } returns entities
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
assertEquals("T-Deck Pro", result?.displayName)
assertEquals("tdeck-pro", result?.platformioTarget)
}
@Test
fun `getDeviceHardwareByModel falls back to first entity when target not found`() = runTest(testDispatcher) {
val hwModel = 50
val target = "unknown-variant"
val entities =
listOf(createEntity(hwModel, "t-deck", "T-Deck"), createEntity(hwModel, "t-deck-tft", "T-Deck TFT"))
coEvery { localDataSource.getByHwModel(hwModel) } returns entities
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
// Should fall back to first entity if no exact match
assertEquals("T-Deck", result?.displayName)
}
@Test
fun `getDeviceHardwareByModel falls back to target lookup when hwModel not found`() = runTest(testDispatcher) {
val hwModel = 0 // Unknown
val target = "tdeck-pro"
val entity = createEntity(102, "tdeck-pro", "T-Deck Pro")
coEvery { localDataSource.getByHwModel(hwModel) } returns emptyList()
coEvery { localDataSource.getByTarget(target) } returns entity
every { bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
val result = repository.getDeviceHardwareByModel(hwModel, target).getOrNull()
assertEquals("T-Deck Pro", result?.displayName)
assertEquals("tdeck-pro", result?.platformioTarget)
}
private fun createEntity(hwModel: Int, target: String, displayName: String) = DeviceHardwareEntity(
activelySupported = true,
architecture = "esp32-s3",
displayName = displayName,
hwModel = hwModel,
hwModelSlug = "T_DECK",
images = listOf("image.svg"), // MUST be non-empty to avoid being considered incomplete/stale
platformioTarget = target,
requiresDfu = false,
supportLevel = 0,
tags = emptyList(),
lastUpdated = System.currentTimeMillis(),
)
}

View file

@ -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')"
]
}
}

View file

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

View file

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

View file

@ -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>?,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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