feat: network module (#1905)

This commit is contained in:
James Rich 2025-05-22 08:30:08 -05:00 committed by GitHub
parent 520d058546
commit 02bb3f02e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 2165 additions and 15032 deletions

View file

@ -35,7 +35,8 @@ data class MyNodeInfo(
val maxChannels: Int,
val hasWifi: Boolean,
val channelUtilization: Float,
val airUtilTx: Float
val airUtilTx: Float,
val deviceId: String?,
) : Parcelable {
/** A human readable description of the software/hardware version */
val firmwareString: String get() = "$model $firmwareVersion"

View file

@ -129,4 +129,20 @@ class Converters : Logging {
fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? {
return value.toByteArray()
}
@TypeConverter
fun fromStringList(value: String?): List<String>? {
if (value == null) {
return null
}
return Json.decodeFromString<List<String>>(value)
}
@TypeConverter
fun toStringList(list: List<String>?): String? {
if (list == null) {
return null
}
return Json.encodeToString(list)
}
}

View file

@ -18,6 +18,8 @@
package com.geeksville.mesh.database
import android.app.Application
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.PacketDao
@ -55,4 +57,14 @@ class DatabaseModule {
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao {
return database.quickChatActionDao()
}
}
@Provides
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao {
return database.deviceHardwareDao()
}
@Provides
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao {
return database.firmwareReleaseDao()
}
}

View file

@ -25,11 +25,15 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
@ -48,6 +52,8 @@ import com.geeksville.mesh.database.entity.ReactionEntity
QuickChatAction::class,
ReactionEntity::class,
MetadataEntity::class,
DeviceHardwareEntity::class,
FirmwareReleaseEntity::class,
],
autoMigrations = [
AutoMigration(from = 3, to = 4),
@ -63,8 +69,9 @@ import com.geeksville.mesh.database.entity.ReactionEntity
AutoMigration(from = 13, to = 14),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17),
],
version = 16,
version = 17,
exportSchema = true,
)
@TypeConverters(Converters::class)
@ -73,6 +80,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun packetDao(): PacketDao
abstract fun meshLogDao(): MeshLogDao
abstract fun quickChatActionDao(): QuickChatActionDao
abstract fun deviceHardwareDao(): DeviceHardwareDao
abstract fun firmwareReleaseDao(): FirmwareReleaseDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase {
@ -82,7 +91,7 @@ abstract class MeshtasticDatabase : RoomDatabase() {
MeshtasticDatabase::class.java,
"meshtastic_database"
)
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigration(false)
.build()
}
}

View file

@ -0,0 +1,36 @@
/*
* 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/>.
*/
package com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity?
@Query("DELETE FROM device_hardware")
suspend fun deleteAll()
}

View file

@ -0,0 +1,40 @@
/*
* 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/>.
*/
package com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()
@Query("SELECT * FROM firmware_release")
suspend fun getAllReleases(): List<FirmwareReleaseEntity>?
@Query("SELECT * FROM firmware_release WHERE release_type = :releaseType")
suspend fun getReleasesByType(releaseType: FirmwareReleaseType): List<FirmwareReleaseEntity>?
}

View file

@ -0,0 +1,77 @@
/*
* 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/>.
*/
package com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.geeksville.mesh.model.DeviceHardware
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import kotlinx.serialization.Serializable
@Serializable
@Entity(tableName = "device_hardware")
data class DeviceHardwareEntity(
@ColumnInfo(name = "actively_supported") val activelySupported: Boolean,
val architecture: String,
@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,
@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,
@ColumnInfo(name = "requires_dfu") val requiresDfu: Boolean?,
@ColumnInfo(name = "support_level") val supportLevel: Int?,
val tags: List<String>?,
)
fun NetworkDeviceHardware.asEntity() = DeviceHardwareEntity(
activelySupported = activelySupported,
architecture = architecture,
displayName = displayName,
hasInkHud = hasInkHud,
hasMui = hasMui,
hwModel = hwModel,
hwModelSlug = hwModelSlug,
images = images,
lastUpdated = System.currentTimeMillis(),
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
supportLevel = supportLevel,
tags = tags,
)
fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
activelySupported = activelySupported,
architecture = architecture,
displayName = displayName,
hasInkHud = hasInkHud,
hasMui = hasMui,
hwModel = hwModel,
hwModelSlug = hwModelSlug,
images = images,
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
supportLevel = supportLevel,
tags = tags,
)

View file

@ -0,0 +1,79 @@
/*
* 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/>.
*/
package com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.geeksville.mesh.network.model.NetworkFirmwareRelease
import kotlinx.serialization.Serializable
@Serializable
@Entity(tableName = "firmware_release")
data class FirmwareReleaseEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = "",
@ColumnInfo(name = "page_url")
val pageUrl: String = "",
@ColumnInfo(name = "release_notes")
val releaseNotes: String = "",
@ColumnInfo(name = "title")
val title: String = "",
@ColumnInfo(name = "zip_url")
val zipUrl: String = "",
@ColumnInfo(name = "last_updated")
val lastUpdated: Long = System.currentTimeMillis(),
@ColumnInfo(name = "release_type")
val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)
fun NetworkFirmwareRelease.asEntity(releaseType: FirmwareReleaseType) = FirmwareReleaseEntity(
id = id,
pageUrl = pageUrl,
releaseNotes = releaseNotes,
title = title,
zipUrl = zipUrl,
lastUpdated = System.currentTimeMillis(),
releaseType = releaseType,
)
fun FirmwareReleaseEntity.asExternalModel() = FirmwareRelease(
id = id,
pageUrl = pageUrl,
releaseNotes = releaseNotes,
title = title,
zipUrl = zipUrl,
lastUpdated = lastUpdated,
releaseType = releaseType,
)
data class FirmwareRelease(
val id: String = "",
val pageUrl: String = "",
val releaseNotes: String = "",
val title: String = "",
val zipUrl: String = "",
val lastUpdated: Long = System.currentTimeMillis(),
val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)
enum class FirmwareReleaseType {
STABLE,
ALPHA
}

View file

@ -34,6 +34,7 @@ data class MyNodeEntity(
val minAppVersion: Int,
val maxChannels: Int,
val hasWifi: Boolean,
val deviceId: String? = "unknown",
) {
/** A human readable description of the software/hardware version */
val firmwareString: String get() = "$model $firmwareVersion"
@ -52,5 +53,6 @@ data class MyNodeEntity(
hasWifi = hasWifi,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = deviceId,
)
}

View file

@ -29,7 +29,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.MeshProtos.HardwareModel
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.Portnums.PortNum
@ -37,9 +36,12 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
@ -56,11 +58,9 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
@ -79,6 +79,9 @@ data class MetricsState(
val tracerouteResults: List<MeshPacket> = emptyList(),
val positionLogs: List<Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
val isLocalDevice: Boolean = false,
val latestStableFirmware: FirmwareRelease? = null,
val latestAlphaFirmware: FirmwareRelease? = null,
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
@ -188,6 +191,7 @@ private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
null
}
@Suppress("LongParameterList")
@HiltViewModel
class MetricsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ -195,6 +199,8 @@ class MetricsViewModel @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val preferences: SharedPreferences,
) : ViewModel(), Logging {
private val destNum = savedStateHandle.toRoute<Route.NodeDetail>().destNum
@ -229,25 +235,23 @@ class MetricsViewModel @Inject constructor(
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _timeFrame
private var deviceHardwareList: List<DeviceHardware> = listOf()
init {
destNum?.let {
radioConfigRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.onEach { (node, ourNode) ->
_state.update { state -> state.copy(node = node, isLocal = destNum == ourNode) }
node?.user?.hwModel?.let { hwModel ->
val deviceHardware = getDeviceHardwareFromHardwareModel(hwModel)
deviceHardware?.let {
_state.update { state ->
state.copy(deviceHardware = it)
}
}
val deviceHardware = node?.user?.hwModel?.number?.let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
}
}
.launchIn(viewModelScope)
_state.update { state ->
state.copy(
node = node,
isLocal = destNum == ourNode,
deviceHardware = deviceHardware
)
}
}.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach { profile ->
val moduleConfig = profile.moduleConfig
@ -308,6 +312,18 @@ class MetricsViewModel @Inject constructor(
}
}.launchIn(viewModelScope)
firmwareReleaseRepository.stableRelease.onEach { latestStable ->
_state.update { state ->
state.copy(latestStableFirmware = latestStable)
}
}.launchIn(viewModelScope)
firmwareReleaseRepository.alphaRelease.onEach { latestAlpha ->
_state.update { state ->
state.copy(latestAlphaFirmware = latestAlpha)
}
}.launchIn(viewModelScope)
debug("MetricsViewModel created")
}
}
@ -361,22 +377,4 @@ class MetricsViewModel @Inject constructor(
errormsg("Can't write file error: ${ex.message}")
}
}
private fun getDeviceHardwareFromHardwareModel(
hwModel: HardwareModel
): DeviceHardware? {
if (deviceHardwareList.isEmpty()) {
try {
val json =
app.assets.open("device_hardware.json").bufferedReader().use { it.readText() }
deviceHardwareList = Json.decodeFromString<List<DeviceHardware>>(json)
return deviceHardwareList.find { it.hwModel == hwModel.number }
} catch (ex: IOException) {
errormsg("Can't read device_hardware.json error: ${ex.message}")
} catch (ex: IllegalArgumentException) {
errormsg(ex.message.toString())
}
}
return null
}
}

View file

@ -0,0 +1,35 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import android.app.Application
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
class DeviceHardwareJsonDataSource @Inject constructor(
private val application: Application,
) {
@OptIn(ExperimentalSerializationApi::class)
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> {
val inputStream = application.assets.open("device_hardware.json")
return Json.decodeFromStream<List<NetworkDeviceHardware>>(inputStream)
}
}

View file

@ -0,0 +1,49 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
import com.geeksville.mesh.database.entity.asEntity
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DeviceHardwareLocalDataSource @Inject constructor(
private val deviceHardwareDaoLazy: dagger.Lazy<DeviceHardwareDao>
) {
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 deleteAllDeviceHardware() = withContext(Dispatchers.IO) {
deviceHardwareDao.deleteAll()
}
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity? = withContext(Dispatchers.IO) {
deviceHardwareDao.getByHwModel(hwModel)
}
}

View file

@ -0,0 +1,85 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.asExternalModel
import com.geeksville.mesh.model.DeviceHardware
import com.geeksville.mesh.network.DeviceHardwareRemoteDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import javax.inject.Inject
class DeviceHardwareRepository @Inject constructor(
private val apiDataSource: DeviceHardwareRemoteDataSource,
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
) {
companion object {
// 1 day
private const val CACHE_EXPIRATION_TIME_MS = 24 * 60 * 60 * 1000L
}
suspend fun getDeviceHardwareByModel(hwModel: Int, refresh: Boolean = false): DeviceHardware? {
return withContext(Dispatchers.IO) {
if (refresh) {
invalidateCache()
} else {
val cachedHardware = localDataSource.getByHwModel(hwModel)
if (cachedHardware != null && !isCacheExpired(cachedHardware.lastUpdated)) {
debug("Using recent cached device hardware")
val externalModel = cachedHardware.asExternalModel()
return@withContext externalModel
}
}
try {
debug("Fetching device hardware from server")
localDataSource.insertAllDeviceHardware(apiDataSource.getAllDeviceHardware())
val cachedHardware = localDataSource.getByHwModel(hwModel)
val externalModel = cachedHardware?.asExternalModel()
return@withContext externalModel
} catch (e: IOException) {
warn("Failed to fetch device hardware from server: ${e.message}")
var cachedHardware = localDataSource.getByHwModel(hwModel)
if (cachedHardware != null) {
debug("Using stale cached device hardware")
return@withContext cachedHardware.asExternalModel()
}
debug("Loading and caching device hardware from local JSON asset")
localDataSource.insertAllDeviceHardware(jsonDataSource.loadDeviceHardwareFromJsonAsset())
cachedHardware = localDataSource.getByHwModel(hwModel)
val externalModel = cachedHardware?.asExternalModel()
return@withContext externalModel
}
}
}
suspend fun invalidateCache() {
localDataSource.deleteAllDeviceHardware()
}
/**
* Check if the cache is expired
*/
private fun isCacheExpired(lastUpdated: Long): Boolean {
return System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}
}

View file

@ -0,0 +1,38 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import android.app.Application
import com.geeksville.mesh.network.model.NetworkFirmwareReleases
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
class FirmwareReleaseJsonDataSource @Inject constructor(
private val application: Application,
) {
@OptIn(ExperimentalSerializationApi::class)
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases {
val inputStream = application.assets.open("firmware_releases.json")
val result = inputStream.use {
Json.decodeFromStream<NetworkFirmwareReleases>(inputStream)
}
return result
}
}

View file

@ -0,0 +1,58 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseType
import com.geeksville.mesh.database.entity.asEntity
import com.geeksville.mesh.network.model.NetworkFirmwareRelease
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class FirmwareReleaseLocalDataSource @Inject constructor(
private val firmwareReleaseDaoLazy: Lazy<FirmwareReleaseDao>
) {
private val firmwareReleaseDao by lazy {
firmwareReleaseDaoLazy.get()
}
suspend fun insertFirmwareReleases(
firmwareReleases: List<NetworkFirmwareRelease>,
releaseType: FirmwareReleaseType
) =
withContext(Dispatchers.IO) {
firmwareReleases.forEach { firmwareRelease ->
firmwareReleaseDao.insert(firmwareRelease.asEntity(releaseType))
}
}
suspend fun deleteAllFirmwareReleases() = withContext(Dispatchers.IO) {
firmwareReleaseDao.deleteAll()
}
suspend fun getLatestRelease(releaseType: FirmwareReleaseType): FirmwareReleaseEntity? =
withContext(Dispatchers.IO) {
val releases = firmwareReleaseDao.getReleasesByType(releaseType)
val latestRelease =
releases?.firstOrNull()
return@withContext latestRelease
}
}

View file

@ -0,0 +1,102 @@
/*
* 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/>.
*/
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.FirmwareReleaseType
import com.geeksville.mesh.database.entity.asExternalModel
import com.geeksville.mesh.network.FirmwareReleaseRemoteDataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.IOException
import javax.inject.Inject
class FirmwareReleaseRepository @Inject constructor(
private val apiDataSource: FirmwareReleaseRemoteDataSource,
private val localDataSource: FirmwareReleaseLocalDataSource,
private val jsonDataSource: FirmwareReleaseJsonDataSource,
) {
companion object {
// 1 hour
private const val CACHE_EXPIRATION_TIME_MS = 60 * 60 * 1000L
}
val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
private fun getLatestFirmware(
releaseType: FirmwareReleaseType,
refresh: Boolean = false
): Flow<FirmwareRelease?> = flow {
if (refresh) {
invalidateCache()
} else {
val cachedRelease = localDataSource.getLatestRelease(releaseType)
if (cachedRelease != null && !isCacheExpired(cachedRelease.lastUpdated)) {
debug("Using recent cached firmware release")
val externalModel = cachedRelease.asExternalModel()
emit(externalModel)
return@flow
}
}
try {
debug("Fetching firmware releases from server")
val networkFirmwareReleases = apiDataSource.getFirmwareReleases()
val releases = when (releaseType) {
FirmwareReleaseType.STABLE -> networkFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> networkFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(
releases,
releaseType
)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
} catch (e: IOException) {
warn("Failed to fetch firmware releases from server: ${e.message}")
val jsonFirmwareReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
val releases = when (releaseType) {
FirmwareReleaseType.STABLE -> jsonFirmwareReleases.releases.stable
FirmwareReleaseType.ALPHA -> jsonFirmwareReleases.releases.alpha
}
localDataSource.insertFirmwareReleases(
releases,
releaseType
)
val cachedRelease = localDataSource.getLatestRelease(releaseType)
val externalModel = cachedRelease?.asExternalModel()
emit(externalModel)
}
}
suspend fun invalidateCache() {
localDataSource.deleteAllFirmwareReleases()
}
/**
* Check if the cache is expired
*/
private fun isCacheExpired(lastUpdated: Long): Boolean {
return System.currentTimeMillis() - lastUpdated > CACHE_EXPIRATION_TIME_MS
}
}

View file

@ -1604,6 +1604,7 @@ class MeshService : Service(), Logging {
minAppVersion = minAppVersion,
maxChannels = 8,
hasWifi = metadata.hasWifi,
deviceId = deviceId.toStringUtf8(),
)
}
serviceScope.handledLaunch {

View file

@ -57,11 +57,11 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Work
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.NoCell
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@ -79,6 +79,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@ -90,7 +91,11 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.model.DeviceHardware
import com.geeksville.mesh.model.MetricsState
import com.geeksville.mesh.model.MetricsViewModel
@ -207,6 +212,33 @@ private fun NodeDetailList(
NodeDetailsContent(node)
}
}
node.metadata?.firmwareVersion?.let { firmwareVersion ->
item {
PreferenceCategory(stringResource(R.string.firmware)) {
val latestStableFirmware = metricsState.latestStableFirmware
val latestAlphaFirmware = metricsState.latestAlphaFirmware
NodeDetailRow(
label = "Installed",
icon = Icons.Default.Memory,
value = firmwareVersion.substringBeforeLast(".")
)
latestStableFirmware?.let { stable ->
NodeDetailRow(
label = "Latest stable",
icon = Icons.Default.Memory,
value = stable.id.substringBeforeLast(".").replace("v", "")
)
}
latestAlphaFirmware?.let { alpha ->
NodeDetailRow(
label = "Latest alpha",
icon = Icons.Default.Memory,
value = alpha.id.substringBeforeLast(".").replace("v", "")
)
}
}
}
}
item {
DeviceActions(
@ -332,25 +364,19 @@ private fun DeviceDetailsContent(
),
contentAlignment = Alignment.Center
) {
DeviceHardwareImage(
deviceHardware = deviceHardware,
modifier = Modifier
.size(100.dp)
)
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
}
NodeDetailRow(
label = stringResource(R.string.hardware),
icon = Icons.Default.Router,
value = hwModelName
)
if (isSupported) {
NodeDetailRow(
label = stringResource(R.string.supported),
icon = Icons.Default.Verified,
value = "",
iconTint = Color.Green
)
}
NodeDetailRow(
label = if (isSupported) stringResource(R.string.supported) else "Supported by Community",
icon = if (isSupported) Icons.TwoTone.Verified else ImageVector.vectorResource(R.drawable.unverified),
value = "",
iconTint = if (isSupported) Color.Green else Color.Red
)
}
@Composable
@ -358,20 +384,37 @@ fun DeviceHardwareImage(
deviceHardware: DeviceHardware,
modifier: Modifier = Modifier,
) {
val hwImg = deviceHardware.images?.lastOrNull()
if (hwImg != null) {
val imageUrl = "file:///android_asset/device_hardware/$hwImg"
AsyncImage(
model = imageUrl,
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(R.drawable.hw_unknown),
error = painterResource(R.drawable.hw_unknown),
fallback = painterResource(R.drawable.hw_unknown),
modifier = modifier
.padding(16.dp)
)
val hwImg = deviceHardware.images?.get(1) ?: deviceHardware.images?.get(0) ?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
val listener = object : ImageRequest.Listener {
override fun onStart(request: ImageRequest) {
super.onStart(request)
debug("Image request started")
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
debug("Image request failed: ${result.throwable.message}")
}
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
super.onSuccess(request, result)
debug("Image request succeeded: ${result.dataSource.name}")
}
}
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.listener(listener)
.data(imageUrl)
.build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(R.drawable.hw_unknown),
error = painterResource(R.drawable.hw_unknown),
fallback = painterResource(R.drawable.hw_unknown),
modifier = modifier
.padding(16.dp)
)
}
@Suppress("LongMethod")
@ -435,13 +478,6 @@ private fun NodeDetailsContent(
value = formatUptime(node.deviceMetrics.uptimeSeconds)
)
}
if (node.metadata != null) {
NodeDetailRow(
label = stringResource(R.string.firmware_version),
icon = Icons.Default.Memory,
value = node.metadata.firmwareVersion.substringBeforeLast(".")
)
}
NodeDetailRow(
label = stringResource(R.string.node_sort_last_heard),
icon = Icons.Default.History,