Modularize remaining nodes code (#3599)

This commit is contained in:
Phil Oliver 2025-11-03 14:43:02 -05:00 committed by GitHub
parent 89bc9528c5
commit 315950b7c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 125 additions and 141 deletions

View file

@ -1,198 +0,0 @@
/*
* 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.model
import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.LightGreen
import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.core.ui.theme.GraphColors.Yellow
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos
@Suppress("MagicNumber")
enum class Environment(val color: Color) {
TEMPERATURE(Red) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
},
HUMIDITY(InfantryBlue) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
},
SOIL_TEMPERATURE(Pink) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature
},
SOIL_MOISTURE(Purple) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
telemetry.environmentMetrics.soilMoisture?.toFloat()
},
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
},
GAS_RESISTANCE(Yellow) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
},
IAQ(Magenta) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
},
LUX(LightGreen) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
},
UV_LUX(Orange) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux
}, ;
abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float?
}
/**
* @param metrics the filtered [List]
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
* @param leftMinMax [Pair] with the min and max of the barometric pressure
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
* @param times [Pair] with the oldest and newest times in that order
*/
data class EnvironmentGraphingData(
val metrics: List<TelemetryProtos.Telemetry>,
val shouldPlot: List<Boolean>,
val leftMinMax: Pair<Float, Float> = Pair(0f, 0f),
val rightMinMax: Pair<Float, Float> = Pair(0f, 0f),
val times: Pair<Int, Int> = Pair(0, 0),
)
data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.Telemetry> = emptyList()) {
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
/**
* Filters [environmentMetrics] based on a [org.meshtastic.feature.node.model.TimeFrame].
*
* @param timeFrame used to filter
* @return [EnvironmentGraphingData]
*/
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
fun environmentMetricsFiltered(timeFrame: TimeFrame, useFahrenheit: Boolean = false): EnvironmentGraphingData {
val oldestTime = timeFrame.calculateOldestTime()
val telemetries = environmentMetrics.filter { it.time >= oldestTime }
val shouldPlot = BooleanArray(Environment.entries.size) { false }
if (telemetries.isEmpty()) {
return EnvironmentGraphingData(metrics = telemetries, shouldPlot = shouldPlot.toList())
}
/* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */
val minValues = mutableListOf<Float>()
val maxValues = mutableListOf<Float>()
// Temperature
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
if (temperatures.isNotEmpty()) {
var minTempValue = temperatures.minOf { it }
var maxTempValue = temperatures.maxOf { it }
if (useFahrenheit) {
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
}
minValues.add(minTempValue)
maxValues.add(maxTempValue)
shouldPlot[Environment.TEMPERATURE.ordinal] = true
}
// Relative Humidity
val humidities =
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
if (humidities.isNotEmpty()) {
minValues.add(humidities.minOf { it })
maxValues.add(humidities.maxOf { it })
shouldPlot[Environment.HUMIDITY.ordinal] = true
}
// Soil Temperature
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
if (soilTemperatures.isNotEmpty()) {
var minSoilTemperatureValue = soilTemperatures.minOf { it }
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
if (useFahrenheit) {
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
}
minValues.add(minSoilTemperatureValue)
maxValues.add(maxSoilTemperatureValue)
shouldPlot[Environment.SOIL_TEMPERATURE.ordinal] = true
}
// Soil Moisture
val soilMoistures =
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
if (soilMoistures.isNotEmpty()) {
minValues.add(soilMoistures.minOf { it.toFloat() })
maxValues.add(soilMoistures.maxOf { it.toFloat() })
shouldPlot[Environment.SOIL_MOISTURE.ordinal] = true
}
// IAQ
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
if (iaqs.isNotEmpty()) {
minValues.add(iaqs.minOf { it.toFloat() })
maxValues.add(iaqs.maxOf { it.toFloat() })
shouldPlot[Environment.IAQ.ordinal] = true
}
// Barometric Pressure
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
var minPressureValue = 0f
var maxPressureValue = 0f
if (pressures.isNotEmpty()) {
minPressureValue = pressures.minOf { it }
maxPressureValue = pressures.maxOf { it }
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true
}
// Lux
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
if (luxValues.isNotEmpty()) {
minValues.add(luxValues.minOf { it })
maxValues.add(luxValues.maxOf { it })
shouldPlot[Environment.LUX.ordinal] = true
}
// UVLux
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
if (uvLuxValues.isNotEmpty()) {
minValues.add(uvLuxValues.minOf { it })
maxValues.add(uvLuxValues.maxOf { it })
shouldPlot[Environment.UV_LUX.ordinal] = true
}
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
val (oldest, newest) = Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time })
return EnvironmentGraphingData(
metrics = telemetries,
shouldPlot = shouldPlot.toList(),
leftMinMax = Pair(minPressureValue, maxPressureValue),
rightMinMax = Pair(min, max),
times = Pair(oldest.time, newest.time),
)
}
}

View file

@ -1,310 +0,0 @@
/*
* 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.model
import android.app.Application
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.util.safeNumber
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
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
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.proto.toPosition
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 org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.Portnums.PortNum
import timber.log.Timber
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
@Suppress("LongParameterList")
@HiltViewModel
class MetricsViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
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 }
/**
* 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)
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
}
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
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 =
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
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
)
}
}
.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) }
}
.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)
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
override fun onCleared() {
super.onCleared()
Timber.d("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) {
Timber.e(ex, "Can't write file error")
}
}
}

View file

@ -39,17 +39,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
import com.geeksville.mesh.ui.metrics.PositionLogScreen
import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
import com.geeksville.mesh.ui.node.NodeDetailScreen
import com.geeksville.mesh.ui.node.NodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
@ -58,6 +47,17 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
fun NavGraphBuilder.nodesGraph(navController: NavHostController) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {

View file

@ -94,7 +94,6 @@ import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
import com.geeksville.mesh.ui.metrics.annotateTraceroute
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -120,6 +119,7 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.feature.node.metrics.annotateTraceroute
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.proto.MeshProtos
import timber.log.Timber

View file

@ -1,330 +0,0 @@
/*
* 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.ui.metrics
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.strings.R
import java.text.DateFormat
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
}
private const val LINE_ON = 10f
private const val LINE_OFF = 20f
private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
private const val DATE_Y = 32f
private const val LINE_LIMIT = 4
private const val TEXT_PAINT_ALPHA = 192
data class LegendData(
val nameRes: Int,
val color: Color,
val isLine: Boolean = false,
val environmentMetric: Environment? = null,
)
@Composable
fun ChartHeader(amount: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$amount ${stringResource(R.string.logs)}",
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
/**
* Draws chart lines with respect to the Y-axis.
*
* @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart.
*/
@Composable
fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List<Color>) {
/* 100 is a good number to divide into quarters */
val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT
Canvas(modifier = modifier) {
val lineStart = 0f
val height = size.height
val width = size.width
/* Horizontal Lines */
var lineY = 0f
for (i in 0..LINE_LIMIT) {
val ratio = lineY / MAX_PERCENT_VALUE
val y = height - (ratio * height)
drawLine(
start = Offset(lineStart, y),
end = Offset(width, y),
color = lineColors[i],
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
lineY += verticalSpacing
}
}
}
/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */
@Composable
fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) {
val range = maxValue - minValue
val verticalSpacing = range / LINE_LIMIT
val density = LocalDensity.current
Canvas(modifier = modifier) {
val height = size.height
/* Y Labels */
val textPaint =
Paint().apply {
color = labelColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
drawContext.canvas.nativeCanvas.apply {
var label = minValue
repeat(LINE_LIMIT + 1) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint)
label += verticalSpacing
}
}
}
}
/** Draws the vertical lines to help the user relate the plotted data within a time frame. */
@Composable
fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) {
val range = newest - oldest
val density = LocalDensity.current
val lineColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
/* Cut out the time remaining in order to place the lines on the dot. */
val timeRemaining = oldest % timeInterval
var current = oldest.toLong()
current -= timeRemaining
current += timeInterval
val textPaint =
Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
/* Vertical Lines with labels */
drawContext.canvas.nativeCanvas.apply {
while (current <= newest) {
val ratio = (current - oldest).toFloat() / range
val x = (ratio * width)
drawLine(
start = Offset(x, 0f),
end = Offset(x, height),
color = lineColor,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
/* Time */
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
/* Date */
drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint)
current += timeInterval
}
}
}
}
/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */
@Composable
fun TimeLabels(oldest: Int, newest: Int) {
Row {
Text(
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
}
}
/**
* Creates the legend that identifies the colors used for the graph.
*
* @param legendData A list containing the `LegendData` to build the labels.
* @param promptInfoDialog Executes when the user presses the info icon.
*/
@Composable
fun Legend(legendData: List<LegendData>, displayInfoIcon: Boolean = true, promptInfoDialog: () -> Unit = {}) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.weight(1f))
legendData.forEachIndexed { index, data ->
LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
if (index != legendData.lastIndex) {
Spacer(modifier = Modifier.weight(1f))
}
}
if (displayInfoIcon) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.Info,
modifier = Modifier.clickable { promptInfoDialog() },
contentDescription = stringResource(R.string.info),
)
}
Spacer(modifier = Modifier.weight(1f))
}
}
/**
* Displays a dialog with information about the legend items.
*
* @param pairedRes A list of `Pair`s containing (term, definition).
* @param onDismiss Executes when the user presses the close button.
*/
@Composable
fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
AlertDialog(
title = {
Text(text = stringResource(R.string.info), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
text = {
Column {
for (pair in pairedRes) {
Text(
text = stringResource(pair.first),
style = TextStyle(fontWeight = FontWeight.Bold),
textDecoration = TextDecoration.Underline,
)
Text(text = stringResource(pair.second), style = TextStyle.Default)
Spacer(modifier = Modifier.height(24.dp))
}
}
},
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } },
shape = RoundedCornerShape(16.dp),
)
}
@Composable
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
Canvas(modifier = Modifier.size(4.dp)) {
if (isLine) {
drawLine(
color = color,
start = Offset(x = 0f, y = size.height / 2f),
end = Offset(x = 16f, y = size.height / 2f),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
} else {
drawCircle(color = color)
}
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
@Preview
@Composable
private fun LegendPreview() {
val data =
listOf(
LegendData(nameRes = R.string.rssi, color = Color.Red),
LegendData(nameRes = R.string.snr, color = Color.Green),
)
Legend(legendData = data, promptInfoDialog = {})
}

View file

@ -1,438 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.plotPoint
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private enum class Device(val color: Color) {
BATTERY(Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
},
CH_UTIL(Magenta) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
},
AIR_UTIL(Cyan) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx
}, ;
abstract fun getValue(telemetry: Telemetry): Float
}
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
LegendData(
nameRes = R.string.channel_utilization,
color = Device.CH_UTIL.color,
isLine = false,
environmentMetric = null,
),
LegendData(
nameRes = R.string.air_utilization,
color = Device.AIR_UTIL.color,
isLine = false,
environmentMetric = null,
),
)
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.deviceMetricsFiltered(selectedTimeFrame)
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.channel_utilization, R.string.ch_util_definition),
Pair(R.string.air_utilization, R.string.air_util_definition),
),
onDismiss = { displayInfoDialog = false },
)
}
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
}
}
}
@Suppress("LongMethod")
@Composable
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
) {
val graphColor = MaterialTheme.colorScheme.onSurface
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) return
val (oldest, newest) =
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
val timeDiff = newest.time - oldest.time
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
Row {
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
) {
/*
* The order of the colors are with respect to the ChUtil.
* 25 - 49 Orange
* 50 - 100 Red
*/
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
)
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
/* Plot Battery Line, ChUtil, and AirUtilTx */
Canvas(modifier = modifier.width(dp)) {
val height = size.height
val width = size.width
for (i in telemetries.indices) {
val telemetry = telemetries[i]
/* x-value time */
val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff
val x = xRatio * width
/* Channel Utilization */
plotPoint(
drawContext = drawContext,
color = Device.CH_UTIL.color,
x = x,
value = telemetry.deviceMetrics.channelUtilization,
divisor = MAX_PERCENT_VALUE,
)
/* Air Utilization Transmit */
plotPoint(
drawContext = drawContext,
color = Device.AIR_UTIL.color,
x = x,
value = telemetry.deviceMetrics.airUtilTx,
divisor = MAX_PERCENT_VALUE,
)
}
/* Battery Line */
var index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = Device.BATTERY.color,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
}
}
}
YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f)
}
Spacer(modifier = Modifier.height(16.dp))
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsChartPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries =
List(20) { i ->
Telemetry.newBuilder()
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(80 - i)
.setVoltage(3.7f - i * 0.02f)
.setChannelUtilization(10f + i * 2)
.setAirUtilTx(5f + i)
.setUptimeSeconds(3600 + i * 300),
)
.build()
}
AppTheme {
DeviceMetricsChart(
modifier = Modifier.height(400.dp),
telemetries = telemetries,
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
promptInfoDialog = {},
)
}
}
@Composable
private fun DeviceMetricsCard(telemetry: Telemetry) {
val deviceMetrics = telemetry.deviceMetrics
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
}
Spacer(modifier = Modifier.height(4.dp))
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val text =
stringResource(R.string.channel_air_util)
.format(deviceMetrics.channelUtilization, deviceMetrics.airUtilTx)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
}
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsCardPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetry =
Telemetry.newBuilder()
.setTime(now)
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(75)
.setVoltage(3.65f)
.setChannelUtilization(22.5f)
.setAirUtilTx(12.0f)
.setUptimeSeconds(7200),
)
.build()
AppTheme { DeviceMetricsCard(telemetry = telemetry) }
}
@Suppress("detekt:MagicNumber") // fake data
@PreviewLightDark
@Composable
private fun DeviceMetricsScreenPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries =
List(24) { i ->
Telemetry.newBuilder()
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(85 - i * 2) // Battery decreases over time
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
.setUptimeSeconds(3600 + i * 3600), // Uptime increases by 1 hour each
)
.build()
}
AppTheme {
Surface {
Column {
var displayInfoDialog by remember { mutableStateOf(false) }
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.channel_utilization, R.string.ch_util_definition),
Pair(R.string.air_utilization, R.string.air_util_definition),
),
onDismiss = { displayInfoDialog = false },
)
}
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries.reversed(),
TimeFrame.TWENTY_FOUR_HOURS,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
TimeFrame.TWENTY_FOUR_HOURS,
onOptionSelected = { /* Preview only */ },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
}
}
}
}
}

View file

@ -1,350 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.model.EnvironmentGraphingData
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
import org.meshtastic.core.strings.R
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos.Telemetry
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
@Suppress("MagicNumber")
private val LEGEND_DATA_1 =
listOf(
LegendData(
nameRes = R.string.temperature,
color = Environment.TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.TEMPERATURE,
),
LegendData(
nameRes = R.string.humidity,
color = Environment.HUMIDITY.color,
isLine = true,
environmentMetric = Environment.HUMIDITY,
),
)
private val LEGEND_DATA_2 =
listOf(
LegendData(
nameRes = R.string.iaq,
color = Environment.IAQ.color,
isLine = true,
environmentMetric = Environment.IAQ,
),
LegendData(
nameRes = R.string.baro_pressure,
color = Environment.BAROMETRIC_PRESSURE.color,
isLine = true,
environmentMetric = Environment.BAROMETRIC_PRESSURE,
),
LegendData(
nameRes = R.string.lux,
color = Environment.LUX.color,
isLine = true,
environmentMetric = Environment.LUX,
),
LegendData(
nameRes = R.string.uv_lux,
color = Environment.UV_LUX.color,
isLine = true,
environmentMetric = Environment.UV_LUX,
),
)
private val LEGEND_DATA_3 =
listOf(
LegendData(
nameRes = R.string.soil_temperature,
color = Environment.SOIL_TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.SOIL_TEMPERATURE,
),
LegendData(
nameRes = R.string.soil_moisture,
color = Environment.SOIL_MOISTURE.color,
isLine = true,
environmentMetric = Environment.SOIL_MOISTURE,
),
)
@Composable
fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
}
val (oldest, newest) = graphData.times
val timeDiff = newest - oldest
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
val shouldPlot = graphData.shouldPlot
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
val visibleWidthPx = screenWidth * chartWidthRatio
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
BarometricPressureYAxisLabel(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
minValue = graphData.leftMinMax.first,
maxValue = graphData.leftMinMax.second,
)
ChartContent(
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
scrollState = scrollState,
dp = dp,
oldest = oldest,
newest = newest,
selectedTime = selectedTime,
telemetries = telemetries,
graphData = graphData,
rightMin = graphData.rightMinMax.first,
rightMax = graphData.rightMinMax.second,
timeDiff = timeDiff,
)
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
MaterialTheme.colorScheme.onSurface,
minValue = graphData.rightMinMax.first,
maxValue = graphData.rightMinMax.second,
)
}
Spacer(modifier = Modifier.height(16.dp))
MetricLegends(graphData = graphData, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:LongMethod")
@Composable
private fun MetricPlottingCanvas(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
oldest: Int,
timeDiff: Int,
rightMin: Float,
rightMax: Float,
) {
val (pressureMin, pressureMax) = graphData.leftMinMax
val shouldPlot = graphData.shouldPlot
val graphColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
var min: Float
var diff: Float
var index: Int
var first: Int
for (metric in Environment.entries) {
if (!shouldPlot[metric.ordinal]) {
continue
}
if (metric == Environment.BAROMETRIC_PRESSURE) {
diff = pressureMax - pressureMin
min = pressureMin
} else { // Reset for other metrics to use rightMin/rightMax
min = rightMin
diff = rightMax - rightMin
}
index = 0
while (index < telemetries.size) {
first = index
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val rawValue = metric.getValue(telemetry) // This is Float?
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
// lux.
val pointValue =
if (rawValue != null && !rawValue.isNaN()) {
rawValue
} else {
0f
}
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
// scale group.
val currentMin = min
// Avoid division by zero if all values in the current y-axis range are the same.
val currentDiff = if (diff == 0f) 1f else diff
val ratio = (pointValue - currentMin) / currentDiff
var y = height - (ratio * height)
// Final check to ensure y is a valid, plottable coordinate.
if (y.isNaN() || y.isInfinite()) {
y = height // Default to the bottom of the chart if calculation still results in an
// invalid number.
} else {
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
}
return@createPath y
}
drawPathWithGradient(
path = path,
color = metric.color,
height = height,
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
)
}
}
}
}
@Composable
private fun BarometricPressureYAxisLabel(
modifier: Modifier,
shouldPlotBarometricPressure: Boolean,
minValue: Float,
maxValue: Float,
) {
if (shouldPlotBarometricPressure) {
YAxisLabels(
modifier = modifier,
Environment.BAROMETRIC_PRESSURE.color,
minValue = minValue,
maxValue = maxValue,
)
}
}
@Composable
private fun ChartContent(
modifier: Modifier = Modifier,
scrollState: ScrollState,
dp: Dp,
oldest: Int,
newest: Int,
selectedTime: TimeFrame,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
rightMin: Float,
rightMax: Float,
timeDiff: Int,
) {
val graphColor = MaterialTheme.colorScheme.onSurface
Box(
contentAlignment = Alignment.TopStart,
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
MetricPlottingCanvas(
modifier = modifier.width(dp),
telemetries = telemetries,
graphData = graphData,
selectedTime = selectedTime,
oldest = oldest,
timeDiff = timeDiff,
rightMin = rightMin,
rightMax = rightMax,
)
}
}
@Composable
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
Legend(LEGEND_DATA_3.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
Legend(
LEGEND_DATA_2.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] },
promptInfoDialog = promptInfoDialog,
)
}
// private const val LINE_ON = 10f
// private const val LINE_OFF = 20f
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
// private const val DATE_Y = 32f
// private const val LINE_LIMIT = 4
// private const val TEXT_PAINT_ALPHA = 192

View file

@ -1,403 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.copy
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
val data = graphData.metrics
val processedTelemetries: List<Telemetry> =
if (state.isFahrenheit) {
data.map { telemetry ->
val temperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.temperature)
val soilTemperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature)
telemetry.copy {
environmentMetrics =
telemetry.environmentMetrics.copy {
temperature = temperatureFahrenheit
soilTemperature = soilTemperatureFahrenheit
}
}
}
} else {
data
}
var displayInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes = listOf(Pair(R.string.iaq, R.string.iaq_definition)),
onDismiss = { displayInfoDialog = false },
)
}
EnvironmentMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = processedTelemetries.reversed(),
graphData = graphData,
selectedTime = selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
}
}
}
}
@Composable
private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
envMetrics.temperature?.let { temperature ->
if (!temperature.isNaN()) {
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
Text(
text = textFormat.format(stringResource(id = R.string.temperature), temperature),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
@Composable
private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasHumidity = envMetrics.relativeHumidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometricPressure?.let { !it.isNaN() && it > 0 } == true
if (hasHumidity || hasPressure) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 0.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (hasHumidity) {
val humidity = envMetrics.relativeHumidity!!
Text(
text = "%s %.2f%%".format(stringResource(id = R.string.humidity), humidity),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
)
}
if (hasPressure) {
val pressure = envMetrics.barometricPressure!!
Text(
text = "%.2f hPa".format(pressure),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
)
}
}
}
}
@Composable
private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
if (
envMetrics.soilTemperature != null ||
(envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE)
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val soilTemperatureTextFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
val soilMoistureTextFormat = "%s %d%%"
envMetrics.soilMoisture?.let { soilMoistureValue ->
if (soilMoistureValue != Int.MIN_VALUE) {
Text(
text = soilMoistureTextFormat.format(stringResource(R.string.soil_moisture), soilMoistureValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
envMetrics.soilTemperature?.let { soilTemperature ->
if (!soilTemperature.isNaN()) {
Text(
text =
soilTemperatureTextFormat.format(
stringResource(R.string.soil_temperature),
soilTemperature,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
}
@Composable
private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux.isNaN()
val hasUvLux = envMetrics.uvLux != null && !envMetrics.uvLux.isNaN()
if (hasLux || hasUvLux) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasLux) {
val luxValue = envMetrics.lux!!
Text(
text = "%s %.0f lx".format(stringResource(R.string.lux), luxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
if (hasUvLux) {
val uvLuxValue = envMetrics.uvLux!!
Text(
text = "%s %.0f UVlx".format(stringResource(R.string.uv_lux), uvLuxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Composable
private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current.isNaN()
if (hasVoltage || hasCurrent) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasVoltage) {
val voltage = envMetrics.voltage!!
Text(
text = "%s %.2f V".format(stringResource(R.string.voltage), voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
if (hasCurrent) {
val current = envMetrics.current!!
Text(
text = "%s %.2f mA".format(stringResource(R.string.current), current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Composable
private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val iaqValue = envMetrics.iaq
val gasResistance = envMetrics.gasResistance
if ((iaqValue != null && iaqValue != Int.MIN_VALUE) || (gasResistance?.isFinite() == true)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (iaqValue != null && iaqValue != Int.MIN_VALUE) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.iaq),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(modifier = Modifier.width(4.dp))
IndoorAirQuality(iaq = iaqValue, displayMode = IaqDisplayMode.Dot)
}
}
if (gasResistance != null && !gasResistance.isNaN()) {
Text(
text = "%s %.2f Ohm".format(stringResource(R.string.gas_resistance), gasResistance),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
// These are in a differnt proto ...
// envMetrics.co2?.let { co2 ->
// Spacer(modifier = Modifier.height(4.dp))
// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
// Text(
// text = "%s %.0f ppm".format(stringResource(R.string.co2), co2),
// color = MaterialTheme.colorScheme.onSurface,
// fontSize = MaterialTheme.typography.labelLarge.fontSize,
// )
// }
// }
// envMetrics.tvoc?.let { tvoc ->
// Spacer(modifier = Modifier.height(4.dp))
// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
// Text(
// text = "%s %.0f ppb".format(stringResource(R.string.tvoc), tvoc),
// color = MaterialTheme.colorScheme.onSurface,
// fontSize = MaterialTheme.typography.labelLarge.fontSize,
// )
// }
// }
}
@Composable
private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
envMetrics.radiation?.let { radiation ->
if (!radiation.isNaN() && radiation > 0f) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f µR/h".format(stringResource(R.string.radiation), radiation),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Composable
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
}
}
@Composable
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
TemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
}
HumidityAndBarometricPressureDisplay(envMetrics)
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)
GasCompositionDisplay(envMetrics)
LuxUVLuxDisplay(envMetrics)
VoltageCurrentDisplay(envMetrics)
RadiationDisplay(envMetrics)
}
}
@Suppress("MagicNumber") // preview data
@Preview(showBackground = true)
@Composable
private fun PreviewEnvironmentMetricsContent() {
// Build a fake EnvironmentMetrics using the generated proto builder APIs
val fakeEnvMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(22.5f)
.setRelativeHumidity(55.0f)
.setBarometricPressure(1013.25f)
.setSoilMoisture(33)
.setSoilTemperature(18.0f)
.setLux(100.0f)
.setUvLux(100.0f)
.setVoltage(3.7f)
.setCurrent(0.12f)
.setIaq(100)
.setRadiation(0.15f)
.setGasResistance(1200.0f)
.build()
val fakeTelemetry =
TelemetryProtos.Telemetry.newBuilder()
.setTime((System.currentTimeMillis() / 1000).toInt())
.setEnvironmentMetrics(fakeEnvMetrics)
.build()
MaterialTheme {
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
}
}

View file

@ -1,246 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DataArray
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.TelemetryProtos
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val hostMetrics = state.hostMetrics
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) }
}
}
}
@Suppress("LongMethod", "MagicNumber")
@Composable
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) {
val hostMetrics = telemetry.hostMetrics
val time = telemetry.time * CommonCharts.MS_PER_SEC
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Row(modifier = Modifier.padding(16.dp)) {
Icon(imageVector = Icons.Default.DataArray, contentDescription = null, modifier = Modifier.width(24.dp))
Spacer(modifier = Modifier.width(16.dp))
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
LogLine(
label = stringResource(R.string.uptime),
value = formatUptime(hostMetrics.uptimeSeconds),
modifier = Modifier.fillMaxWidth(),
)
LogLine(
label = stringResource(R.string.free_memory),
value = formatBytes(hostMetrics.freememBytes),
modifier = Modifier.fillMaxWidth(),
)
LogLine(
label = stringResource(R.string.disk_free_indexed, 1),
value = formatBytes(hostMetrics.diskfree1Bytes),
modifier = Modifier.fillMaxWidth(),
)
if (hostMetrics.hasDiskfree2Bytes()) {
LogLine(
label = stringResource(R.string.disk_free_indexed, 2),
value = formatBytes(hostMetrics.diskfree2Bytes),
modifier = Modifier.fillMaxWidth(),
)
}
if (hostMetrics.hasDiskfree3Bytes()) {
LogLine(
label = stringResource(R.string.disk_free_indexed, 3),
value = formatBytes(hostMetrics.diskfree3Bytes),
modifier = Modifier.fillMaxWidth(),
)
}
LogLine(
label = stringResource(R.string.load_indexed, 1),
value = (hostMetrics.load1 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load1 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
LogLine(
label = stringResource(R.string.load_indexed, 5),
value = (hostMetrics.load5 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load5 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
LogLine(
label = stringResource(R.string.load_indexed, 15),
value = (hostMetrics.load15 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load15 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
if (hostMetrics.hasUserString()) {
Text(text = stringResource(R.string.user_string), style = MaterialTheme.typography.bodyMedium)
Text(text = hostMetrics.userString, style = TextStyle(fontFamily = FontFamily.Monospace))
}
}
}
}
}
}
@Composable
fun LogLine(modifier: Modifier = Modifier, label: String, value: String) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label)
Text(text = value)
}
}
const val BYTES_IN_KB = 1024.0
const val BYTES_IN_MB = BYTES_IN_KB * 1024.0
const val BYTES_IN_GB = BYTES_IN_MB * 1024.0
fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
val formatter =
DecimalFormat().apply {
maximumFractionDigits = decimalPlaces
minimumFractionDigits = 0
isGroupingUsed = false
}
return when {
bytes < 0 -> "N/A" // Handle negative bytes gracefully
bytes == 0L -> "0 B"
bytes >= BYTES_IN_GB -> "${formatter.format(bytes / BYTES_IN_GB)} GB"
bytes >= BYTES_IN_MB -> "${formatter.format(bytes / BYTES_IN_MB)} MB"
bytes >= BYTES_IN_KB -> "${formatter.format(bytes / BYTES_IN_KB)} KB"
else -> "$bytes B"
}
}
@Suppress("MagicNumber")
@PreviewLightDark
@Composable
private fun HostMetricsItemPreview() {
val hostMetrics =
TelemetryProtos.HostMetrics.newBuilder()
.setUptimeSeconds(3600)
.setFreememBytes(2048000)
.setDiskfree1Bytes(104857600)
.setDiskfree2Bytes(2097915200)
.setDiskfree3Bytes(44444)
.setLoad1(30)
.setLoad5(75)
.setLoad15(19)
.setUserString("test")
.build()
val logs =
TelemetryProtos.Telemetry.newBuilder()
.setTime((System.currentTimeMillis() / 1000L).toInt())
.setHostMetrics(hostMetrics)
.build()
AppTheme { HostMetricsItem(telemetry = logs) }
}

View file

@ -1,325 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
import java.text.DateFormat
import java.util.Date
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private enum class PaxSeries(val color: Color, val legendRes: Int) {
PAX(Color.Black, R.string.pax),
BLE(Color.Cyan, R.string.ble_devices),
WIFI(Color.Green, R.string.wifi_devices),
}
@Suppress("LongMethod")
@Composable
private fun PaxMetricsChart(
modifier: Modifier = Modifier,
totalSeries: List<Pair<Int, Int>>,
bleSeries: List<Pair<Int, Int>>,
wifiSeries: List<Pair<Int, Int>>,
minValue: Float,
maxValue: Float,
timeFrame: TimeFrame,
) {
if (totalSeries.isEmpty()) return
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val times = totalSeries.map { it.first }
val minTime = times.minOrNull() ?: 0
val maxTime = times.maxOrNull() ?: 1
val timeDiff = maxTime - minTime
val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) }
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
val visibleOldest = minTime + (timeDiff * leftRatio).toInt()
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray })
TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval())
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
val width = size.width
val height = size.height
fun xForTime(t: Int): Float =
if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width
fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height
fun drawLine(series: List<Pair<Int, Int>>, color: Color) {
for (i in 1 until series.size) {
drawLine(
color = color,
start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)),
end = Offset(xForTime(series[i].first), yForValue(series[i].second)),
strokeWidth = 2.dp.toPx(),
)
}
}
drawLine(bleSeries, PaxSeries.BLE.color)
drawLine(wifiSeries, PaxSeries.WIFI.color)
drawLine(totalSeries, PaxSeries.PAX.color)
}
}
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val dateFormat = DateFormat.getDateTimeInstance()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
// Only show logs that can be decoded as PaxcountProtos.Paxcount
val paxMetrics =
state.paxMetrics.mapNotNull { log ->
val pax = decodePaxFromLog(log)
if (pax != null) {
Pair(log, pax)
} else {
null
}
}
// Prepare data for graph
val oldestTime = timeFrame.calculateOldestTime()
val graphData =
paxMetrics
.filter { it.first.received_date / 1000 >= oldestTime }
.map {
val t = (it.first.received_date / 1000).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
val totalSeries = graphData.map { it.first to (it.second + it.third) }
val bleSeries = graphData.map { it.first to it.second }
val wifiSeries = graphData.map { it.first to it.third }
val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f)
val minValue = 0f
val legendData =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
)
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Time frame selector
SlidingSelector(
options = TimeFrame.entries.toList(),
selectedOption = timeFrame,
onOptionSelected = { timeFrame = it },
) { tf: TimeFrame ->
OptionLabel(stringResource(tf.strRes))
}
// Graph
if (graphData.isNotEmpty()) {
ChartHeader(graphData.size)
Legend(legendData = legendData)
PaxMetricsChart(
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
minValue = minValue,
maxValue = maxValue,
timeFrame = timeFrame,
)
}
// List
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(R.string.no_pax_metrics_logs),
modifier = Modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
}
}
}
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod")
fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
var result: PaxcountProtos.Paxcount? = null
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
if (packet != null && packet.hasDecoded() && packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax
}
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e)
} catch (e: IllegalArgumentException) {
android.util.Log.e("PaxMetrics", "Invalid argument while parsing Paxcount from binary data", e)
}
// Fallback: Try direct base64 or bytes from raw_message
if (result == null) {
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
result = pax
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
result = pax
}
} catch (e: IllegalArgumentException) {
android.util.Log.e("PaxMetrics", "Invalid Base64 or hex input", e)
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e)
}
}
return result
}
@Suppress("MagicNumber")
fun unescapeProtoString(escaped: String): ByteArray {
val out = mutableListOf<Byte>()
var i = 0
while (i < escaped.length) {
if (escaped[i] == '\\' && i + 3 < escaped.length && escaped[i + 1].isDigit()) {
// Octal escape: \\ddd
val octal = escaped.substring(i + 1, i + 4)
out.add(octal.toInt(8).toByte())
i += 4
} else {
out.add(escaped[i].code.toByte())
i++
}
}
return out.toByteArray()
}
@Composable
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateFormat.format(Date(log.received_date)),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
val total = pax.ble + pax.wifi
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = summary,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f, fill = true),
)
Text(
text = stringResource(R.string.uptime) + ": " + formatUptime(pax.uptime),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
modifier = Modifier.alignByBaseline(),
)
}
}
}

View file

@ -1,268 +0,0 @@
/*
* 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.ui.metrics
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.proto.formatPositionTime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.MeshProtos
import java.text.DateFormat
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
Text(
text = text,
modifier = Modifier.weight(weight),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
private const val WEIGHT_10 = .10f
private const val WEIGHT_15 = .15f
private const val WEIGHT_20 = .20f
private const val WEIGHT_40 = .40f
@Composable
private fun HeaderItem(compactWidth: Boolean) {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
PositionText(stringResource(R.string.latitude), WEIGHT_20)
PositionText(stringResource(R.string.longitude), WEIGHT_20)
PositionText(stringResource(R.string.sats), WEIGHT_10)
PositionText(stringResource(R.string.alt), WEIGHT_15)
if (!compactWidth) {
PositionText(stringResource(R.string.speed), WEIGHT_15)
PositionText(stringResource(R.string.heading), WEIGHT_15)
}
PositionText(stringResource(R.string.timestamp), WEIGHT_40)
}
}
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, dateFormat: DateFormat, system: DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format(position.latitudeI * DEG_D), WEIGHT_20)
PositionText("%.5f".format(position.longitudeI * DEG_D), WEIGHT_20)
PositionText(position.satsInView.toString(), WEIGHT_10)
PositionText(position.altitude.metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.groundSpeed} Km/h", WEIGHT_15)
PositionText("%.0f°".format(position.groundTrack * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(dateFormat), WEIGHT_40)
}
}
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
onClear: () -> Unit,
saveButtonEnabled: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.clear))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(id = R.string.clear))
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(id = R.string.save))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(id = R.string.save))
}
}
}
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
}
}
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
HeaderItem(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "position.csv")
}
exportPositionLauncher.launch(intent)
},
)
}
}
}
}
@Composable
private fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<MeshProtos.Position>,
displayUnits: DisplayUnits,
) {
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, dateFormat, displayUnits) }
}
}
@Suppress("MagicNumber")
private val testPosition =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = 297604270
longitudeI = -953698040
altitude = 1230
satsInView = 7
time = (System.currentTimeMillis() / 1000).toInt()
}
.build()
@Preview(showBackground = true)
@Composable
private fun PositionItemPreview() {
AppTheme {
PositionItem(
compactWidth = false,
position = testPosition,
dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
system = DisplayUnits.METRIC,
)
}
}
@PreviewScreenSizes
@Composable
private fun ActionButtonsPreview() {
AppTheme {
Column(Modifier.fillMaxSize(), Arrangement.Bottom) {
ActionButtons(clearButtonEnabled = true, onClear = {}, saveButtonEnabled = true, onSave = {})
}
}
}

View file

@ -1,386 +0,0 @@
/*
* 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.ui.metrics
import androidx.annotation.StringRes
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos.Telemetry
import kotlin.math.ceil
import kotlin.math.floor
@Suppress("MagicNumber")
private enum class Power(val color: Color, val min: Float, val max: Float) {
CURRENT(InfantryBlue, -500f, 500f),
;
/** Difference between the metrics `max` and `min` values. */
fun difference() = max - min
}
private enum class PowerChannel(@StringRes val strRes: Int) {
ONE(R.string.channel_1),
TWO(R.string.channel_2),
THREE(R.string.channel_3),
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private const val VOLTAGE_STICK_TO_ZERO_RANGE = 2f
private val VOLTAGE_COLOR = Red
fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
val valueMin = floor(valueMin)
val min =
if (valueMin == 0f || (valueMin >= 0f && valueMin - VOLTAGE_STICK_TO_ZERO_RANGE <= 0f)) {
0f
} else {
valueMin - VOLTAGE_STICK_TO_ZERO_RANGE
}
val max = ceil(valueMax)
return Pair(min, max)
}
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
)
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val data = state.powerMetricsFiltered(selectedTimeFrame)
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
PowerMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
selectedChannel,
)
SlidingSelector(
PowerChannel.entries.toList(),
selectedChannel,
onOptionSelected = { selectedChannel = it },
) {
OptionLabel(stringResource(it.strRes))
}
Spacer(modifier = Modifier.height(2.dp))
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
}
}
}
@Suppress("LongMethod")
@Composable
private fun PowerMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
selectedTime: TimeFrame,
selectedChannel: PowerChannel,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
}
val (oldest, newest) =
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
val timeDiff = newest.time - oldest.time
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by
remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val currentDiff = Power.CURRENT.difference()
val (voltageMin, voltageMax) =
minMaxGraphVoltage(
retrieveVoltage(selectedChannel, telemetries.minBy { retrieveVoltage(selectedChannel, it) }),
retrieveVoltage(selectedChannel, telemetries.maxBy { retrieveVoltage(selectedChannel, it) }),
)
val voltageDiff = voltageMax - voltageMin
Row {
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Power.CURRENT.color,
minValue = Power.CURRENT.min,
maxValue = Power.CURRENT.max,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
/* Plot */
Canvas(modifier = modifier.width(dp)) {
val width = size.width
val height = size.height
/* Voltage */
var index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = (retrieveVoltage(selectedChannel, telemetry) - voltageMin) / voltageDiff
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = VOLTAGE_COLOR,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
}
/* Current */
index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = Power.CURRENT.color,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
}
}
}
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
VOLTAGE_COLOR,
minValue = voltageMin,
maxValue = voltageMax,
)
}
Spacer(modifier = Modifier.height(16.dp))
Legend(legendData = LEGEND_DATA, displayInfoIcon = false)
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
private fun PowerMetricsCard(telemetry: Telemetry) {
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp)) {
/* Time */
Row {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
PowerChannelColumn(
R.string.channel_1,
telemetry.powerMetrics.ch1Voltage,
telemetry.powerMetrics.ch1Current,
)
}
if (telemetry.powerMetrics.hasCh2Current() || telemetry.powerMetrics.hasCh2Voltage()) {
PowerChannelColumn(
R.string.channel_2,
telemetry.powerMetrics.ch2Voltage,
telemetry.powerMetrics.ch2Current,
)
}
if (telemetry.powerMetrics.hasCh3Current() || telemetry.powerMetrics.hasCh3Voltage()) {
PowerChannelColumn(
R.string.channel_3,
telemetry.powerMetrics.ch3Voltage,
telemetry.powerMetrics.ch3Current,
)
}
}
}
}
}
}
}
}
@Composable
private fun PowerChannelColumn(@StringRes titleRes: Int, voltage: Float, current: Float) {
Column {
Text(
text = stringResource(titleRes),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Text(
text = "%.2fV".format(voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Text(
text = "%.1fmA".format(current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
/** Retrieves the appropriate voltage depending on `channelSelected`. */
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage
PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage
PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage
}
/** Retrieves the appropriate current depending on `channelSelected`. */
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.powerMetrics.ch1Current
PowerChannel.TWO -> telemetry.powerMetrics.ch2Current
PowerChannel.THREE -> telemetry.powerMetrics.ch3Current
}

View file

@ -1,290 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil.plotPoint
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.component.SnrAndRssi
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.MeshProtos.MeshPacket
@Suppress("MagicNumber")
private enum class Metric(val color: Color, val min: Float, val max: Float) {
SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */
RSSI(Color.Blue, -140f, -20f),
;
/** Difference between the metrics `max` and `min` values. */
fun difference() = max - min
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color, environmentMetric = null),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color, environmentMetric = null),
)
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.signalMetricsFiltered(selectedTimeFrame)
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.snr, R.string.snr_definition),
Pair(R.string.rssi, R.string.rssi_definition),
),
onDismiss = { displayInfoDialog = false },
)
}
SignalMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
) {
ChartHeader(amount = meshPackets.size)
if (meshPackets.isEmpty()) {
return
}
val (oldest, newest) =
remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) }
val timeDiff = newest.rxTime - oldest.rxTime
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by
remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val snrDiff = Metric.SNR.difference()
val rssiDiff = Metric.RSSI.difference()
Row {
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.RSSI.color,
minValue = Metric.RSSI.min,
maxValue = Metric.RSSI.max,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(
modifier.width(dp),
oldest = oldest.rxTime,
newest = newest.rxTime,
selectedTime.lineInterval(),
)
/* Plot SNR and RSSI */
Canvas(modifier = modifier.width(dp)) {
val width = size.width
/* Plot */
for (packet in meshPackets) {
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
val x = xRatio * width
/* SNR */
plotPoint(
drawContext = drawContext,
color = Metric.SNR.color,
x = x,
value = packet.rxSnr - Metric.SNR.min,
divisor = snrDiff,
)
/* RSSI */
plotPoint(
drawContext = drawContext,
color = Metric.RSSI.color,
x = x,
value = packet.rxRssi - Metric.RSSI.min,
divisor = rssiDiff,
)
}
}
}
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.SNR.color,
minValue = Metric.SNR.min,
maxValue = Metric.SNR.max,
)
}
Spacer(modifier = Modifier.height(16.dp))
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket) {
val time = meshPacket.rxTime * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
SnrAndRssi(meshPacket.rxSnr, meshPacket.rxRssi)
}
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi)
}
}
}
}
}
}

View file

@ -1,282 +0,0 @@
/*
* 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.ui.metrics
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.proto.MeshProtos
import java.text.DateFormat
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
if (showDialog != null) {
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
SimpleAlertDialog(
title = R.string.traceroute,
text = { SelectionContainer { Text(text = message) } },
onDismiss = { showDialog = null },
)
}
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val result =
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
state.tracerouteResults.find {
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
}
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
val time = dateFormat.format(log.received_date)
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
val tracerouteDetailsAnnotated: AnnotatedString? =
result?.let { res ->
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
val seconds =
(res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
val annotatedBase =
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
buildAnnotatedString {
append(annotatedBase)
append("\n\nDuration: ${"%.1f".format(seconds)} s")
}
} else {
// For cases where there's a result but no full route, display plain text
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
}
}
Box {
TracerouteItem(
icon = icon,
text = "$time - $text",
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
if (tracerouteDetailsAnnotated != null) {
showDialog = tracerouteDetailsAnnotated
} else if (result != null) {
// Fallback for results that couldn't be fully annotated but have basic info
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (basicInfo != null) {
showDialog = AnnotatedString(basicInfo)
}
}
},
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DeleteItem {
viewModel.deleteLog(log.uuid)
expanded = false
}
}
}
}
}
}
}
@Composable
private fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.delete),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = stringResource(id = R.string.delete), color = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = icon, contentDescription = stringResource(id = R.string.traceroute))
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
}
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
this == null -> {
stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff
}
// A direct route means the sender and receiver are the only two nodes in the route.
routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
stringResource(R.string.traceroute_direct) to Icons.Default.Group
}
routeCount == routeBackCount -> {
val hops = routeCount - 2
pluralStringResource(R.plurals.traceroute_hops, hops, hops) to Icons.Default.Groups
}
else -> {
// Asymmetric route
val towards = maxOf(0, routeCount - 2)
val back = maxOf(0, routeBackCount - 2)
stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups
}
}
/**
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
*
* @param inString The raw string output from a traceroute response.
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
*/
@Composable
fun annotateTraceroute(inString: String?): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "⇊ -8.75 dB SNR"
if (line.trimStart().startsWith("")) {
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
else -> MaterialTheme.colorScheme.StatusOrange
}
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
} else {
// Append line as is if SNR value cannot be parsed
append(line)
}
} else {
// Append non-SNR lines as is
append(line)
}
}
}
}
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
AppTheme {
TracerouteItem(icon = Icons.Default.Group, text = "${dateFormat.format(System.currentTimeMillis())} - Direct")
}
}

View file

@ -1,168 +0,0 @@
/*
* 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.ui.node
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.MetricsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
fun NodeDetailContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
lastTracerouteTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
onSaveNotes: (nodeNum: Int, notes: String) -> Unit,
modifier: Modifier = Modifier,
) {
var showShareDialog by remember { mutableStateOf(false) }
if (showShareDialog) {
SharedContactDialog(node) { showShareDialog = false }
}
NodeDetailList(
node = node,
lastTracerouteTime = lastTracerouteTime,
ourNode = ourNode,
metricsState = metricsState,
onAction = { action ->
if (action is NodeDetailAction.ShareContact) {
showShareDialog = true
} else {
onAction(action)
}
},
modifier = modifier,
availableLogs = availableLogs,
onSaveNotes = onSaveNotes,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeDetailList(
node: Node,
lastTracerouteTime: Long?,
ourNode: Node?,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set<LogsType>,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
if (showFirmwareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) {
selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
}
}
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (metricsState.deviceHardware != null) {
DeviceDetailsSection(metricsState)
}
NodeDetailsSection(node)
NotesSection(node = node, onSaveNotes = onSaveNotes)
DeviceActions(
isLocal = metricsState.isLocal,
lastTracerouteTime = lastTracerouteTime,
node = node,
onAction = onAction,
)
PositionSection(
node = node,
ourNode = ourNode,
metricsState = metricsState,
availableLogs = availableLogs,
onAction = onAction,
)
MetricsSection(node, metricsState, availableLogs, onAction)
if (!metricsState.isManaged) {
AdministrationSection(
node = node,
metricsState = metricsState,
onAction = onAction,
onFirmwareSelect = { firmware ->
selectedFirmware = firmware
showFirmwareSheet = true
},
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
NodeDetailList(
node = node,
ourNode = node,
lastTracerouteTime = null,
metricsState = MetricsState.Companion.Empty,
availableLogs = emptySet(),
onAction = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -1,158 +0,0 @@
/*
* 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.ui.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.NodeDetailAction
@Suppress("LongMethod")
@Composable
fun NodeDetailScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val availableLogs by
remember(state, environmentState) {
derivedStateOf {
buildSet {
if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
if (state.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
if (state.hasPowerMetrics()) add(LogsType.POWER)
if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (state.hasHostMetrics()) add(LogsType.HOST)
if (state.hasPaxMetrics()) add(LogsType.PAX)
}
}
}
val node = state.node
@Suppress("ModifierNotUsedAtRoot")
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.longName ?: "",
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
if (node != null) {
@Suppress("ViewModelForwarding")
NodeDetailContent(
node = node,
ourNode = ourNode,
metricsState = state,
lastTracerouteTime = lastTracerouteTime,
availableLogs = availableLogs,
onAction = { action ->
handleNodeAction(
action = action,
ourNode = ourNode,
node = node,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
handleNodeMenuAction = { nodeDetailViewModel.handleNodeMenuAction(it) },
)
},
modifier = modifier.padding(paddingValues),
onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) },
)
} else {
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
private fun handleNodeAction(
action: NodeDetailAction,
ourNode: Node?,
node: Node,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: MetricsViewModel,
handleNodeMenuAction: (NodeMenuAction) -> Unit,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("$channel${node.user.id}")
}
is NodeMenuAction.Remove -> {
handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> handleNodeMenuAction(menuAction)
}
}
is NodeDetailAction.ShareContact -> {
/* Handled in NodeDetailContent */
}
}
}

View file

@ -1,281 +0,0 @@
/*
* 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.ui.node
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DoDisturbOn
import androidx.compose.material.icons.outlined.DoDisturbOn
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.component.supportsQrCodeSharing
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.feature.node.list.NodeListViewModel
import org.meshtastic.proto.AdminProtos
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val listState = rememberLazyListState()
val currentTimeMillis = rememberTimeTickWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.nodes),
subtitle = stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount),
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = {},
)
},
floatingActionButton = {
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
val sharedContact: AdminProtos.SharedContact? by
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
alignment = Alignment.BottomEnd,
),
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
)
},
) { contentPadding ->
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
stickyHeader {
val animatedAlpha by
animateFloatAsState(targetValue = if (!isScrollInProgress) 1.0f else 0f, label = "alpha")
NodeFilterTextField(
modifier =
Modifier.fillMaxWidth()
.graphicsLayer(alpha = animatedAlpha)
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
filterText = state.filter.filterText,
onTextChange = viewModel::setNodeFilterText,
currentSortOption = state.sort,
onSortSelect = viewModel::setSortOption,
includeUnknown = state.filter.includeUnknown,
onToggleIncludeUnknown = viewModel::toggleIncludeUnknown,
onlyOnline = state.filter.onlyOnline,
onToggleOnlyOnline = viewModel::toggleOnlyOnline,
onlyDirect = state.filter.onlyDirect,
onToggleOnlyDirect = viewModel::toggleOnlyDirect,
showIgnored = state.filter.showIgnored,
onToggleShowIgnored = viewModel::toggleShowIgnored,
ignoredNodeCount = ignoredNodeCount,
)
}
items(nodes, key = { it.num }) { node ->
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = viewModel::favoriteNode,
onConfirmIgnore = viewModel::ignoreNode,
onConfirmRemove = { viewModel.removeNode(it.num) },
)
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
val longClick =
if (node.num != ourNode?.num) {
{ expanded = true }
} else {
null
}
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
isConnected = connectionState.isConnected(),
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
ContextMenu(
expanded = expanded,
node = node,
onClickFavorite = { displayFavoriteDialog = true },
onClickIgnore = { displayIgnoreDialog = true },
onClickRemove = { displayRemoveDialog = true },
onDismiss = { expanded = false },
)
}
}
}
item { Spacer(modifier = Modifier.height(88.dp)) }
}
}
}
}
@Composable
private fun ContextMenu(
expanded: Boolean,
node: Node,
onClickFavorite: (Node) -> Unit,
onClickIgnore: (Node) -> Unit,
onClickRemove: (Node) -> Unit,
onDismiss: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
val isFavorite = node.isFavorite
val isIgnored = node.isIgnored
DropdownMenuItem(
onClick = {
onClickFavorite(node)
onDismiss()
},
enabled = !isIgnored,
leadingIcon = {
Icon(
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = null,
)
},
text = { Text(stringResource(if (isFavorite) R.string.remove_favorite else R.string.add_favorite)) },
)
DropdownMenuItem(
onClick = {
onClickIgnore(node)
onDismiss()
},
leadingIcon = {
Icon(
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(if (isIgnored) R.string.remove_ignored else R.string.ignore),
color = MaterialTheme.colorScheme.StatusRed,
)
},
)
DropdownMenuItem(
onClick = {
onClickRemove(node)
onDismiss()
},
enabled = !isIgnored,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = if (isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
)
},
text = {
Text(
text = stringResource(R.string.remove),
color = if (isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
)
},
)
}
}

View file

@ -1,122 +0,0 @@
/*
* 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.util
import android.content.res.Resources
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.drawscope.DrawContext
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import org.meshtastic.proto.TelemetryProtos.Telemetry
object GraphUtil {
val RADIUS = Resources.getSystem().displayMetrics.density * 2
/**
* @param value Must be zero-scaled before passing.
* @param divisor The range for the data set.
*/
fun plotPoint(drawContext: DrawContext, color: Color, x: Float, value: Float, divisor: Float) {
val height = drawContext.size.height
val ratio = value / divisor
val y = height - (ratio * height)
drawContext.canvas.drawCircle(
center = Offset(x, y),
radius = RADIUS,
paint = androidx.compose.ui.graphics.Paint().apply { this.color = color },
)
}
/**
* Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` or the last point
* before a time separation between [Telemetry]s.
*
* @param telemetries data used to create the [Path]
* @param index current place in the [List]
* @param path [Path] that will be used to draw
* @param timeRange The time range for the data set
* @param width of the [DrawContext]
* @param timeThreshold to determine significant breaks in time between [Telemetry]s
* @param calculateY (`index`) -> `y` coordinate
* @return the current index after iterating
*/
fun createPath(
telemetries: List<Telemetry>,
index: Int,
path: Path,
oldestTime: Int,
timeRange: Int,
width: Float,
timeThreshold: Long,
calculateY: (Int) -> Float,
): Int {
var i = index
var isNewLine = true
with(path) {
while (i < telemetries.size) {
val telemetry = telemetries[i]
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
/* Check to see if we have a significant time break between telemetries. */
if (nextTelemetry.time - telemetry.time > timeThreshold) {
i++
break
}
val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange
val x1 = x1Ratio * width
val y1 = calculateY(i)
val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange
val x2 = x2Ratio * width
val y2 = calculateY(i + 1)
if (isNewLine || i == 0) {
isNewLine = false
moveTo(x1, y1)
}
quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f)
i++
}
}
return i
}
fun DrawScope.drawPathWithGradient(path: Path, color: Color, height: Float, x1: Float, x2: Float) {
drawPath(path = path, color = color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round))
val fillPath =
android.graphics.Path(path.asAndroidPath()).asComposePath().apply {
lineTo(x1, height)
lineTo(x2, height)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(colors = listOf(color.copy(alpha = 0.5f), Color.Transparent), endY = height),
)
}
}

View file

@ -1,52 +0,0 @@
/*
* 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.util
import org.meshtastic.proto.MeshProtos
import timber.log.Timber
/**
* Safely extracts the hardware model number from a HardwareModel enum.
*
* This function handles unknown enum values gracefully by catching IllegalArgumentException and returning a fallback
* value. This prevents crashes when the app receives data from devices with hardware models not yet defined in the
* current protobuf version.
*
* @param fallbackValue The value to return if the enum is unknown (defaults to 0 for UNSET)
* @return The hardware model number, or the fallback value if the enum is unknown
*/
@Suppress("detekt:SwallowedException")
fun MeshProtos.HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try {
this.number
} catch (e: IllegalArgumentException) {
Timber.w("Unknown hardware model enum value: $this, using fallback value: $fallbackValue")
fallbackValue
}
/**
* Checks if the hardware model is a known/supported value.
*
* @return true if the hardware model is known and supported, false otherwise
*/
@Suppress("detekt:SwallowedException")
fun MeshProtos.HardwareModel.isKnown(): Boolean = try {
this.number
true
} catch (e: IllegalArgumentException) {
false
}