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