diff --git a/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt new file mode 100644 index 000000000..6c0ef9ac7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt @@ -0,0 +1,147 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.model + +import androidx.compose.ui.graphics.Color +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.ui.theme.InfantryBlue +import com.geeksville.mesh.ui.theme.Orange + +enum class Environment(val color: Color) { + TEMPERATURE(Color.Red) { + override fun getValue(telemetry: Telemetry): Float { + return telemetry.environmentMetrics.temperature + } + }, + HUMIDITY(InfantryBlue) { + override fun getValue(telemetry: Telemetry): Float { + return telemetry.environmentMetrics.relativeHumidity + } + }, + IAQ(Color.Green) { + override fun getValue(telemetry: Telemetry): Float { + return telemetry.environmentMetrics.iaq.toFloat() + } + }, + BAROMETRIC_PRESSURE(Orange) { + override fun getValue(telemetry: Telemetry): Float { + return telemetry.environmentMetrics.barometricPressure + } + }; + + abstract fun getValue(telemetry: 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, + val shouldPlot: List, + val leftMinMax: Pair = Pair(0f, 0f), + val rightMinMax: Pair = Pair(0f, 0f), + val times: Pair = Pair(0, 0) +) + +data class EnvironmentMetricsState( + val environmentMetrics: List = emptyList(), +) { + fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() + + /** + * Filters [environmentMetrics] based on a [TimeFrame]. + * + * @param timeFrame used to filter + * @return [EnvironmentGraphingData] + */ + @Suppress("LongMethod") + fun environmentMetricsFiltered(timeFrame: TimeFrame): 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, and iaq. */ + val minValues = mutableListOf() + val maxValues = mutableListOf() + val (minTemp, maxTemp) = Pair( + telemetries.minBy { it.environmentMetrics.temperature }, + telemetries.maxBy { it.environmentMetrics.temperature } + ) + if (minTemp.environmentMetrics.temperature != 0f || maxTemp.environmentMetrics.temperature != 0f) { + minValues.add(minTemp.environmentMetrics.temperature) + maxValues.add(maxTemp.environmentMetrics.temperature) + shouldPlot[Environment.TEMPERATURE.ordinal] = true + } + + val (minHumidity, maxHumidity) = Pair( + telemetries.minBy { it.environmentMetrics.relativeHumidity }, + telemetries.maxBy { it.environmentMetrics.relativeHumidity } + ) + if (minHumidity.environmentMetrics.relativeHumidity != 0f || + maxHumidity.environmentMetrics.relativeHumidity != 0f) { + minValues.add(minHumidity.environmentMetrics.relativeHumidity) + maxValues.add(maxHumidity.environmentMetrics.relativeHumidity) + shouldPlot[Environment.HUMIDITY.ordinal] = true + } + + val (minIAQ, maxIAQ) = Pair( + telemetries.minBy { it.environmentMetrics.iaq }, + telemetries.maxBy { it.environmentMetrics.iaq } + ) + if (minIAQ.environmentMetrics.iaq != 0 || maxIAQ.environmentMetrics.iaq != 0) { + minValues.add(minIAQ.environmentMetrics.iaq.toFloat()) + maxValues.add(maxIAQ.environmentMetrics.iaq.toFloat()) + shouldPlot[Environment.IAQ.ordinal] = true + } + + val min = minValues.minOf { it } + val max = maxValues.maxOf { it } + + val (minPressure, maxPressure) = Pair( + telemetries.minBy { it.environmentMetrics.barometricPressure }, + telemetries.maxBy { it.environmentMetrics.barometricPressure } + ) + if (minPressure.environmentMetrics.barometricPressure != 0.0F && + maxPressure.environmentMetrics.barometricPressure != 0.0F) { + shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true + } + val (oldest, newest) = Pair( + telemetries.minBy { it.time }, + telemetries.maxBy { it.time } + ) + + return EnvironmentGraphingData( + metrics = telemetries, + shouldPlot = shouldPlot.toList(), + leftMinMax = Pair( + minPressure.environmentMetrics.barometricPressure, + maxPressure.environmentMetrics.barometricPressure + ), + rightMinMax = Pair(min, max), + times = Pair(oldest.time, newest.time) + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index f0d910b57..e320be062 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -71,7 +71,6 @@ data class MetricsState( val displayUnits: DisplayUnits = DisplayUnits.METRIC, val node: Node? = null, val deviceMetrics: List = emptyList(), - val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), val powerMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), @@ -80,7 +79,6 @@ data class MetricsState( val deviceHardware: DeviceHardware? = null, ) { fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() - fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() fun hasSignalMetrics() = signalMetrics.isNotEmpty() fun hasPowerMetrics() = powerMetrics.isNotEmpty() fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() @@ -91,11 +89,6 @@ data class MetricsState( return deviceMetrics.filter { it.time >= oldestTime } } - fun environmentMetricsFiltered(timeFrame: TimeFrame): List { - val oldestTime = timeFrame.calculateOldestTime() - return environmentMetrics.filter { it.time >= oldestTime } - } - fun signalMetricsFiltered(timeFrame: TimeFrame): List { val oldestTime = timeFrame.calculateOldestTime() return signalMetrics.filter { it.rxTime >= oldestTime } @@ -217,6 +210,9 @@ class MetricsViewModel @Inject constructor( private val _state = MutableStateFlow(MetricsState.Empty) val state: StateFlow = _state + private val _envState = MutableStateFlow(EnvironmentMetricsState()) + val environmentState: StateFlow = _envState + private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) val timeFrame: StateFlow = _timeFrame @@ -253,12 +249,16 @@ class MetricsViewModel @Inject constructor( _state.update { state -> state.copy( deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, + powerMetrics = telemetry.filter { it.hasPowerMetrics() } + ) + } + _envState.update { state -> + state.copy( environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f && !it.environmentMetrics.temperature.isNaN() }, - powerMetrics = telemetry.filter { it.hasPowerMetrics() } ) } }.launchIn(viewModelScope) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index b207346df..ebba0abe4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -74,6 +74,7 @@ import androidx.compose.material.icons.filled.Work import androidx.compose.material.icons.outlined.Navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -109,6 +110,20 @@ import com.geeksville.mesh.util.formatUptime import com.geeksville.mesh.util.thenIf import kotlin.math.ln +private enum class LogsType( + val titleRes: Int, + val icon: ImageVector, + val route: Route +) { + DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, Route.DeviceMetrics), + NODE_MAP(R.string.node_map, Icons.Default.Map, Route.NodeMap), + POSITIONS(R.string.position_log, Icons.Default.LocationOn, Route.PositionLog), + ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, Route.EnvironmentMetrics), + SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, Route.SignalMetrics), + POWER(R.string.power_metrics_log, Icons.Default.Power, Route.PowerMetrics), + TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog) +} + @Composable fun NodeDetailScreen( modifier: Modifier = Modifier, @@ -116,6 +131,19 @@ fun NodeDetailScreen( onNavigate: (Route) -> Unit, ) { val state by viewModel.state.collectAsStateWithLifecycle() + val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() + + /* The order is with respect to the enum above: LogsType */ + val availabilities = remember(key1 = state, key2 = environmentState) { + booleanArrayOf( + state.hasDeviceMetrics(), + state.hasPositionLogs(), + state.hasPositionLogs(), + environmentState.hasEnvironmentMetrics(), + state.hasSignalMetrics(), + state.hasPowerMetrics(), + state.hasTracerouteLogs()) + } if (state.node != null) { val node = state.node ?: return @@ -124,6 +152,7 @@ fun NodeDetailScreen( metricsState = state, onNavigate = onNavigate, modifier = modifier, + metricsAvailability = availabilities ) } else { Box( @@ -141,6 +170,7 @@ private fun NodeDetailList( node: Node, metricsState: MetricsState, onNavigate: (Route) -> Unit = {}, + metricsAvailability: BooleanArray ) { LazyColumn( modifier = modifier.fillMaxSize(), @@ -175,9 +205,18 @@ private fun NodeDetailList( } } + /* Metric Logs Navigation */ item { PreferenceCategory(stringResource(id = R.string.logs)) - LogNavigationList(metricsState, onNavigate) + for (type in LogsType.entries) { + NavCard( + title = stringResource(type.titleRes), + icon = type.icon, + enabled = metricsAvailability[type.ordinal] + ) { + onNavigate(type.route) + } + } } if (!metricsState.isManaged) { @@ -345,65 +384,6 @@ private fun NodeDetailsContent( ) } -@Composable -fun LogNavigationList(state: MetricsState, onNavigate: (Route) -> Unit) { - NavCard( - title = stringResource(R.string.device_metrics_log), - icon = Icons.Default.ChargingStation, - enabled = state.hasDeviceMetrics() - ) { - onNavigate(Route.DeviceMetrics) - } - - NavCard( - title = stringResource(R.string.node_map), - icon = Icons.Default.Map, - enabled = state.hasPositionLogs() - ) { - onNavigate(Route.NodeMap) - } - - NavCard( - title = stringResource(R.string.position_log), - icon = Icons.Default.LocationOn, - enabled = state.hasPositionLogs() - ) { - onNavigate(Route.PositionLog) - } - - NavCard( - title = stringResource(R.string.env_metrics_log), - icon = Icons.Default.Thermostat, - enabled = state.hasEnvironmentMetrics() - ) { - onNavigate(Route.EnvironmentMetrics) - } - - NavCard( - title = stringResource(R.string.sig_metrics_log), - icon = Icons.Default.SignalCellularAlt, - enabled = state.hasSignalMetrics() - ) { - onNavigate(Route.SignalMetrics) - } - - NavCard( - title = stringResource(R.string.power_metrics_log), - icon = Icons.Default.Power, - enabled = state.hasPowerMetrics() - ) { - onNavigate(Route.PowerMetrics) - } - - NavCard( - title = stringResource(R.string.traceroute_log), - icon = Icons.Default.Route, - enabled = state.hasTracerouteLogs() - ) { - onNavigate(Route.TracerouteLog) - } -} - @Composable private fun InfoCard( icon: ImageVector, @@ -646,6 +626,7 @@ private fun NodeDetailsPreview( NodeDetailList( node = node, metricsState = MetricsState.Empty, + metricsAvailability = BooleanArray(LogsType.entries.size) { false } ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt index 08a1c27ec..375ba7811 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.ui.components +import android.annotation.SuppressLint import androidx.compose.foundation.Canvas import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -46,7 +47,6 @@ 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.platform.LocalConfiguration import androidx.compose.ui.res.stringResource @@ -58,39 +58,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Environment +import com.geeksville.mesh.model.EnvironmentGraphingData import com.geeksville.mesh.model.MetricsViewModel import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT -import com.geeksville.mesh.ui.theme.InfantryBlue -import com.geeksville.mesh.ui.theme.Orange import com.geeksville.mesh.util.GraphUtil.createPath import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient -private enum class Environment(val color: Color) { - TEMPERATURE(Color.Red) { - override fun getValue(telemetry: Telemetry): Float { - return telemetry.environmentMetrics.temperature - } - }, - HUMIDITY(InfantryBlue) { - override fun getValue(telemetry: Telemetry): Float { - return telemetry.environmentMetrics.relativeHumidity - } - }, - IAQ(Color.Green) { - override fun getValue(telemetry: Telemetry): Float { - return telemetry.environmentMetrics.iaq.toFloat() - } - }, - BAROMETRIC_PRESSURE(Orange) { - override fun getValue(telemetry: Telemetry): Float { - return telemetry.environmentMetrics.barometricPressure - } - }; - - abstract fun getValue(telemetry: Telemetry): Float -} private val LEGEND_DATA_1 = listOf( LegendData( nameRes = R.string.temperature, @@ -121,8 +97,10 @@ fun EnvironmentMetricsScreen( viewModel: MetricsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() val selectedTimeFrame by viewModel.timeFrame.collectAsState() - val data = state.environmentMetricsFiltered(selectedTimeFrame) + val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame) + val data = graphData.metrics /* Convert Celsius to Fahrenheit */ @Suppress("MagicNumber") @@ -161,6 +139,7 @@ fun EnvironmentMetricsScreen( .fillMaxWidth() .fillMaxHeight(fraction = 0.33f), telemetries = processedTelemetries.reversed(), + graphData = graphData, selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true } ) @@ -187,11 +166,14 @@ fun EnvironmentMetricsScreen( } } -@Suppress("LongMethod", "CyclomaticComplexMethod", "ComplexCondition") +/* TODO need to take the time to understand this. */ +@SuppressLint("ConfigurationScreenWidthHeight") +@Suppress("LongMethod") @Composable private fun EnvironmentMetricsChart( modifier: Modifier = Modifier, telemetries: List, + graphData: EnvironmentGraphingData, selectedTime: TimeFrame, promptInfoDialog: () -> Unit ) { @@ -199,88 +181,37 @@ private fun EnvironmentMetricsChart( 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 (oldest, newest) = graphData.times TimeLabels( - oldest = oldest.time, - newest = newest.time + oldest = oldest, + newest = newest ) Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - /* Grab the combined min and max for all data being plotted. */ - val (minTemp, maxTemp) = remember(key1 = telemetries) { - Pair( - telemetries.minBy { it.environmentMetrics.temperature }, - telemetries.maxBy { it.environmentMetrics.temperature } - ) - } - val (minHumidity, maxHumidity) = remember(key1 = telemetries) { - Pair( - telemetries.minBy { it.environmentMetrics.relativeHumidity }, - telemetries.maxBy { it.environmentMetrics.relativeHumidity } - ) - } - val minValues = mutableListOf( - minTemp.environmentMetrics.temperature, - minHumidity.environmentMetrics.relativeHumidity - ) - val maxValues = mutableListOf( - maxTemp.environmentMetrics.temperature, - maxHumidity.environmentMetrics.relativeHumidity - ) - val (minIAQ, maxIAQ) = remember(key1 = telemetries) { - Pair( - telemetries.minBy { it.environmentMetrics.iaq }, - telemetries.maxBy { it.environmentMetrics.iaq } - ) - } - var plotIAQ = false - if (minIAQ.environmentMetrics.iaq != 0 && maxIAQ.environmentMetrics.iaq != 0) { - minValues.add(minIAQ.environmentMetrics.iaq.toFloat()) - maxValues.add(maxIAQ.environmentMetrics.iaq.toFloat()) - plotIAQ = true - } - - var min = minValues.minOf { it } - val max = maxValues.maxOf { it } - var diff = max - min - - val (minPressure, maxPressure) = remember(key1 = telemetries) { - Pair( - telemetries.minBy { it.environmentMetrics.barometricPressure }, - telemetries.maxBy { it.environmentMetrics.barometricPressure } - ) - } - var plotPressure = false - val pressureDiff = - maxPressure.environmentMetrics.barometricPressure - minPressure.environmentMetrics.barometricPressure - if (minPressure.environmentMetrics.barometricPressure != 0.0F && - maxPressure.environmentMetrics.barometricPressure != 0.0F) { - plotPressure = true - } + val (rightMin, rightMax) = graphData.rightMinMax + val (pressureMin, pressureMax) = graphData.leftMinMax + var min = rightMin + var diff = rightMax - rightMin val scrollState = rememberScrollState() val screenWidth = LocalConfiguration.current.screenWidthDp + val timeDiff = newest - oldest val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) } + val shouldPlot = graphData.shouldPlot Row { - if (plotPressure) { + if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) { YAxisLabels( modifier = modifier.weight(weight = .1f), Environment.BAROMETRIC_PRESSURE.color, - minValue = minPressure.environmentMetrics.barometricPressure, - maxValue = maxPressure.environmentMetrics.barometricPressure + minValue = pressureMin, + maxValue = pressureMax ) } Box( @@ -297,8 +228,8 @@ private fun EnvironmentMetricsChart( TimeAxisOverlay( modifier = modifier.width(dp), - oldest = oldest.time, - newest = newest.time, + oldest = oldest, + newest = newest, selectedTime.lineInterval() ) @@ -309,13 +240,13 @@ private fun EnvironmentMetricsChart( var index: Int var first: Int for (metric in Environment.entries) { - if (metric == Environment.IAQ && !plotIAQ || - metric == Environment.BAROMETRIC_PRESSURE && !plotPressure) { + + if (!shouldPlot[metric.ordinal]) { continue } if (metric == Environment.BAROMETRIC_PRESSURE) { - diff = pressureDiff - min = minPressure.environmentMetrics.barometricPressure + diff = pressureMax - pressureMin + min = pressureMin } index = 0 while (index < telemetries.size) { @@ -325,7 +256,7 @@ private fun EnvironmentMetricsChart( telemetries = telemetries, index = index, path = path, - oldestTime = oldest.time, + oldestTime = oldest, timeRange = timeDiff, width = width, timeThreshold = selectedTime.timeThreshold() @@ -339,8 +270,8 @@ private fun EnvironmentMetricsChart( path = path, color = metric.color, height = height, - x1 = ((telemetries[index - 1].time - oldest.time).toFloat() / timeDiff) * width, - x2 = ((telemetries[first].time - oldest.time).toFloat() / timeDiff) * width + x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width, + x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width ) } } @@ -349,8 +280,8 @@ private fun EnvironmentMetricsChart( YAxisLabels( modifier = modifier.weight(weight = .1f), graphColor, - minValue = min, - maxValue = max + minValue = rightMin, + maxValue = rightMax ) }