/*
* Copyright (c) 2024 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 com.geeksville.mesh.model
import android.app.Application
import android.content.SharedPreferences
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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
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.MeshLog
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.ui.Route
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
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
import javax.inject.Inject
data class MetricsState(
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
val node: NodeEntity? = null,
val deviceMetrics: List = emptyList(),
val environmentMetrics: List = emptyList(),
val signalMetrics: List = emptyList(),
val tracerouteRequests: List = emptyList(),
val tracerouteResults: List = emptyList(),
val positionLogs: List = emptyList(),
val deviceHardware: DeviceHardware? = null,
@DrawableRes val deviceImageRes: Int = R.drawable.hw_unknown,
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
fun hasPositionLogs() = positionLogs.isNotEmpty()
fun deviceMetricsFiltered(timeFrame: TimeFrame): List {
val oldestTime = timeFrame.calculateOldestTime()
return deviceMetrics.filter { it.time >= oldestTime }
}
fun environmentMetricsFiltered(timeFrame: TimeFrame): List {
val oldestTime = timeFrame.calculateOldestTime()
return environmentMetrics.filter { it.time >= oldestTime }
}
fun signalMetricsFiltered(timeFrame: TimeFrame): List {
val oldestTime = timeFrame.calculateOldestTime()
return signalMetrics.filter { it.rxTime >= oldestTime }
}
companion object {
val Empty = MetricsState()
}
}
/**
* Supported time frames used to display data.
*/
@Suppress("MagicNumber")
enum class TimeFrame(
val seconds: Long,
@StringRes val strRes: Int
) {
TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), R.string.twenty_four_hours),
FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), R.string.forty_eight_hours),
ONE_WEEK(TimeUnit.DAYS.toSeconds(7), R.string.one_week),
TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), R.string.two_weeks),
FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), R.string.four_weeks),
MAX(0L, R.string.max);
fun calculateOldestTime(): Long = if (this == MAX) {
MAX.seconds
} else {
System.currentTimeMillis() / 1000 - this.seconds
}
/**
* The time interval to draw the vertical lines representing
* time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long {
return when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
}
/**
* Calculates the needed [Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {
val timePerScreen = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal,
FORTY_EIGHT_HOURS.ordinal ->
TimeUnit.HOURS.toSeconds(1)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal ->
TimeUnit.DAYS.toSeconds(1)
else ->
TimeUnit.DAYS.toSeconds(7)
}
val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
runCatching { Position.parseFrom(decoded.payload) }.getOrNull()
} else {
null
}
@HiltViewModel
class MetricsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val preferences: SharedPreferences,
) : ViewModel(), Logging {
private val destNum = savedStateHandle.toRoute().destNum
private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == destNum
}
fun getUser(nodeNum: Int) = radioConfigRepository.getUser(nodeNum)
val tileSource get() = CustomTileSource.getTileSource(preferences.getInt(MAP_STYLE_ID, 0))
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) {
meshLogRepository.deleteLog(uuid)
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
meshLogRepository.deleteLogs(destNum, PortNum.POSITION_APP_VALUE)
}
private val _state = MutableStateFlow(MetricsState.Empty)
val state: StateFlow = _state
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow = _timeFrame
init {
@OptIn(ExperimentalCoroutinesApi::class)
radioConfigRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] }
.distinctUntilChanged()
.onEach { node ->
_state.update { state -> state.copy(node = node) }
node?.user?.hwModel?.let { hwModel ->
_state.update { state ->
state.copy(
deviceHardware = getDeviceHardwareFromHardwareModel(hwModel),
deviceImageRes = getDeviceVectorImageFromHardwareModel(hwModel)
)
}
}
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
)
}
}.launchIn(viewModelScope)
meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
environmentMetrics = telemetry.filter {
it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f
}
)
}
}.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.getMeshPacketsFrom(destNum, 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)
}
}.launchIn(viewModelScope)
debug("MetricsViewModel created")
}
override fun onCleared() {
super.onCleared()
debug("MetricsViewModel cleared")
}
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/**
* Write the persisted Position data out to a CSV file in the specified location.
*/
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\"")
val dateFormat =
SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
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\"")
}
}
}
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) }
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
private var deviceHardwareList: List = listOf()
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>(json)
} catch (ex: IOException) {
errormsg("Can't read device_hardware.json error: ${ex.message}")
}
}
return deviceHardwareList.find { it.hwModel == hwModel.number }
}
@Suppress("CyclomaticComplexMethod")
private fun getDeviceVectorImageFromHardwareModel(hwModel: HardwareModel): Int {
return when (hwModel) {
HardwareModel.DIY_V1 -> R.drawable.hw_diy
HardwareModel.HELTEC_HT62 -> R.drawable.hw_heltec_ht62_esp32c3_sx1262
HardwareModel.HELTEC_MESH_NODE_T114 -> R.drawable.hw_heltec_mesh_node_t114
HardwareModel.HELTEC_V3 -> R.drawable.hw_heltec_v3
HardwareModel.HELTEC_VISION_MASTER_E213 -> R.drawable.hw_heltec_vision_master_e213
HardwareModel.HELTEC_VISION_MASTER_E290 -> R.drawable.hw_heltec_vision_master_e290
HardwareModel.HELTEC_VISION_MASTER_T190 -> R.drawable.hw_heltec_vision_master_t190
HardwareModel.HELTEC_WIRELESS_PAPER -> R.drawable.hw_heltec_wireless_paper
HardwareModel.HELTEC_WIRELESS_TRACKER -> R.drawable.hw_heltec_wireless_tracker
HardwareModel.HELTEC_WIRELESS_TRACKER_V1_0 -> R.drawable.hw_heltec_wireless_tracker_v1_0
HardwareModel.HELTEC_WSL_V3 -> R.drawable.hw_heltec_wsl_v3
HardwareModel.NANO_G2_ULTRA -> R.drawable.hw_nano_g2_ultra
HardwareModel.RPI_PICO -> R.drawable.hw_pico
HardwareModel.NRF52_PROMICRO_DIY -> R.drawable.hw_promicro
HardwareModel.RAK11310 -> R.drawable.hw_rak11310
HardwareModel.RAK4631 -> R.drawable.hw_rak4631
HardwareModel.RPI_PICO2 -> R.drawable.hw_rpipicow
HardwareModel.SENSECAP_INDICATOR -> R.drawable.hw_seeed_sensecap_indicator
HardwareModel.SEEED_XIAO_S3 -> R.drawable.hw_seeed_xiao_s3
HardwareModel.STATION_G2 -> R.drawable.hw_station_g2
HardwareModel.T_DECK -> R.drawable.hw_t_deck
HardwareModel.T_ECHO -> R.drawable.hw_t_echo
HardwareModel.T_WATCH_S3 -> R.drawable.hw_t_watch_s3
HardwareModel.TBEAM -> R.drawable.hw_tbeam
HardwareModel.LILYGO_TBEAM_S3_CORE -> R.drawable.hw_tbeam_s3_core
HardwareModel.TLORA_C6 -> R.drawable.hw_tlora_c6
HardwareModel.TLORA_T3_S3 -> R.drawable.hw_tlora_t3s3_v1
HardwareModel.TLORA_V2_1_1P6 -> R.drawable.hw_tlora_v2_1_1_6
HardwareModel.TLORA_V2_1_1P8 -> R.drawable.hw_tlora_v2_1_1_8
HardwareModel.TRACKER_T1000_E -> R.drawable.hw_tracker_t1000_e
HardwareModel.WIO_WM1110 -> R.drawable.hw_wio_tracker_wm1110
HardwareModel.WISMESH_TAP -> R.drawable.hw_rak_wismeshtap
else -> R.drawable.hw_unknown
}
}
}