Meshtastic-Android/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt

311 lines
13 KiB
Kotlin
Raw Normal View History

/*
2025-01-02 06:50:26 -03:00
* 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/>.
*/
2024-08-31 04:05:42 -07:00
package com.geeksville.mesh.model
2024-11-02 13:23:04 -03:00
import android.app.Application
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
2024-08-31 04:05:42 -07:00
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.MeshProtos
2024-10-23 13:31:31 -07:00
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.Portnums
2024-10-25 08:14:32 -03:00
import com.geeksville.mesh.Portnums.PortNum
2025-08-30 13:00:51 +10:00
import com.geeksville.mesh.util.safeNumber
2024-08-31 04:05:42 -07:00
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
2024-11-02 13:23:04 -03:00
import kotlinx.coroutines.withContext
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
2025-09-24 16:23:05 -04:00
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
2025-09-24 11:43:46 -04:00
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.NodesRoutes
2025-09-23 05:51:03 -04:00
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.proto.toPosition
2025-09-30 16:55:56 -04:00
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import timber.log.Timber
2024-11-02 13:23:04 -03:00
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
2024-08-31 04:05:42 -07:00
import javax.inject.Inject
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
2024-11-02 13:23:04 -03:00
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
2025-05-22 08:30:08 -05:00
@Suppress("LongParameterList")
2024-08-31 04:05:42 -07:00
@HiltViewModel
class MetricsViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
2024-11-02 13:23:04 -03:00
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
2025-05-22 08:30:08 -05:00
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
2024-10-25 08:14:32 -03:00
/**
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
* freezing when viewing unknown nodes.
*/
private fun createFallbackNode(nodeNum: Int): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = app.getString(R.string.fallback_node_name, safeUserId)
val defaultUser =
MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(longName)
.setShortName(safeUserId)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
return Node(num = nodeNum, user = defaultUser)
}
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
2024-10-25 08:14:32 -03:00
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
2024-11-02 13:23:04 -03:00
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
2024-11-02 13:23:04 -03:00
}
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
private val _state = MutableStateFlow(MetricsState.Empty)
val state: StateFlow<MetricsState> = _state
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
2025-05-08 08:31:07 -07:00
2024-11-11 12:54:26 -08:00
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _timeFrame
init {
if (destNum != null) {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.onEach { (node, ourNode) ->
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(destNum)
val deviceHardware =
2025-08-30 13:00:51 +10:00
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
}
_state.update { state ->
state.copy(
node = actualNode,
isLocal = destNum == ourNode,
deviceHardware = deviceHardware.getOrNull(),
)
}
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow
.onEach { profile ->
val moduleConfig = profile.moduleConfig
2025-05-22 08:30:08 -05:00
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
2025-05-22 08:30:08 -05:00
)
}
}
.launchIn(viewModelScope)
meshLogRepository
.getTelemetryFrom(destNum)
.onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
)
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum)
.onEach { meshPackets ->
_state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
}
.launchIn(viewModelScope)
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
.onEach { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
2025-05-22 08:30:08 -05:00
}
.launchIn(viewModelScope)
meshLogRepository
.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
.onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.stableRelease
.filterNotNull()
.onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.alphaRelease
.filterNotNull()
.onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
.launchIn(viewModelScope)
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.distinctUntilChanged()
.onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
.launchIn(viewModelScope)
2025-05-22 08:30:08 -05:00
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
override fun onCleared() {
super.onCleared()
Timber.d("MetricsViewModel cleared")
}
2024-08-31 04:05:42 -07:00
2024-11-11 12:54:26 -08:00
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Write the persisted Position data out to a CSV file in the specified location. */
2024-11-02 13:23:04 -03:00
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
writer.appendLine(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"",
)
2024-11-02 13:23:04 -03:00
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
2024-11-02 13:23:04 -03:00
positions.forEach { position ->
val rxDateTime = dateFormat.format(position.time * 1000L)
val latitude = position.latitudeI * 1e-7
val longitude = position.longitudeI * 1e-7
val altitude = position.altitude
val satsInView = position.satsInView
val speed = position.groundSpeed
val heading = "%.2f".format(position.groundTrack * 1e-5)
// date,time,latitude,longitude,altitude,satsInView,speed,heading
writer.appendLine(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
)
2024-11-02 13:23:04 -03:00
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) =
withContext(dispatchers.io) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
2024-11-02 13:23:04 -03:00
}
} catch (ex: FileNotFoundException) {
Timber.e(ex, "Can't write file error")
2024-11-02 13:23:04 -03:00
}
}
2024-08-31 04:05:42 -07:00
}