From b067a0c0b3bab0ca179e9e439bad8ed200f32ac5 Mon Sep 17 00:00:00 2001 From: Robert-0410 <62630290+Robert-0410@users.noreply.github.com> Date: Sat, 15 Feb 2025 20:37:05 -0800 Subject: [PATCH] feat: Power graph (#1556) * Refactor: We can draw the horizontal lines for the graphs independent of min and max entries. * Added navigation to the PowerMetrics log with a skeleton screen. * Drew channel 1 voltage. * Refactor: Assigned colors for the data within the enum instead of a list-ordinal combo. * Plotted Ch1 current line. * Refactor: Did not need the parameters being used to get the desired ui in the TimeLabels composable. * Added a row to help distinguish between units. * Refactor: MetricsTimeSelector.kt to SlidingSelector.kt; the new version allows for generic options. * Added a sliding selector to choose between power channels and changed the legend data to instead display current and voltage. * We now plot the line for which the user has selected a power channel option. * Don't need the current line to be dotted anymore. * Don't think we need to display an info dialog for voltage and current. * Wrote card to display the power channel data entries. * detekt * Refactor: current color change to accommodate the themes better --------- Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../geeksville/mesh/model/MetricsViewModel.kt | 10 +- .../mesh/navigation/NodeDetailNavigation.kt | 7 + .../com/geeksville/mesh/navigation/Route.kt | 1 + .../java/com/geeksville/mesh/ui/NodeDetail.kt | 8 + .../mesh/ui/components/CommonCharts.kt | 48 +-- .../mesh/ui/components/DeviceMetrics.kt | 32 +- .../mesh/ui/components/EnvironmentMetrics.kt | 35 +- .../mesh/ui/components/PowerMetrics.kt | 371 ++++++++++++++++++ .../mesh/ui/components/SignalMetrics.kt | 29 +- ...ricsTimeSelector.kt => SlidingSelector.kt} | 78 ++-- .../com/geeksville/mesh/util/GraphUtil.kt | 1 + app/src/main/res/values/strings.xml | 6 + 12 files changed, 514 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt rename app/src/main/java/com/geeksville/mesh/ui/components/{MetricsTimeSelector.kt => SlidingSelector.kt} (88%) 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 f55e1508..e4c2c731 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -74,6 +74,7 @@ data class MetricsState( val deviceMetrics: List = emptyList(), val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), + val powerMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), val positionLogs: List = emptyList(), @@ -82,6 +83,7 @@ data class MetricsState( fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() fun hasSignalMetrics() = signalMetrics.isNotEmpty() + fun hasPowerMetrics() = powerMetrics.isNotEmpty() fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() fun hasPositionLogs() = positionLogs.isNotEmpty() @@ -100,6 +102,11 @@ data class MetricsState( return signalMetrics.filter { it.rxTime >= oldestTime } } + fun powerMetricsFiltered(timeFrame: TimeFrame): List { + val oldestTime = timeFrame.calculateOldestTime() + return powerMetrics.filter { it.time >= oldestTime } + } + companion object { val Empty = MetricsState() } @@ -247,7 +254,8 @@ class MetricsViewModel @Inject constructor( deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f - } + }, + powerMetrics = telemetry.filter { it.hasPowerMetrics() } ) } }.launchIn(viewModelScope) diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt index 76796cb7..057a9da0 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt @@ -28,6 +28,7 @@ import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen import com.geeksville.mesh.ui.components.NodeMapScreen import com.geeksville.mesh.ui.components.PositionLogScreen +import com.geeksville.mesh.ui.components.PowerMetricsScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen @@ -67,6 +68,12 @@ fun NavGraphBuilder.addNodDetailSection(navController: NavController) { viewModel = hiltViewModel(parentEntry), ) } + composable { + val parentEntry = remember { navController.getBackStackEntry() } + PowerMetricsScreen( + viewModel = hiltViewModel(parentEntry), + ) + } composable { val parentEntry = remember { navController.getBackStackEntry() } TracerouteLogScreen( diff --git a/app/src/main/java/com/geeksville/mesh/navigation/Route.kt b/app/src/main/java/com/geeksville/mesh/navigation/Route.kt index 9aa2e7df..459c22c1 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/Route.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/Route.kt @@ -66,5 +66,6 @@ sealed interface Route { @Serializable data object PositionLog : Route @Serializable data object EnvironmentMetrics : Route @Serializable data object SignalMetrics : Route + @Serializable data object PowerMetrics : Route @Serializable data object TracerouteLog : Route } 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 e656e2ad..c14de547 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -363,6 +363,14 @@ fun LogNavigationList(state: MetricsState, onNavigate: (Route) -> Unit) { 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, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt index 0444a2f2..17400398 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -61,6 +61,7 @@ import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MAX_PERCENT_VALUE import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import java.text.DateFormat @@ -71,12 +72,13 @@ object CommonCharts { const val MS_PER_SEC = 1000L const val LINE_LIMIT = 4 const val TEXT_PAINT_ALPHA = 192 + const val MAX_PERCENT_VALUE = 100f } -private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) -private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) 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 data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false) @@ -104,7 +106,7 @@ fun ChartHeader(amount: Int) { * @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart. * @param leaveSpace When true the lines will leave space for Y labels on the left side of the graph. */ -@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLines()", level = DeprecationLevel.WARNING) +@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLinesOverlay()", level = DeprecationLevel.WARNING) @Composable fun ChartOverlay( modifier: Modifier, @@ -166,7 +168,7 @@ fun ChartOverlay( } /** - * Draws chart lines with respect to the Y-axis range; defined by (`maxValue` - `minValue`). + * 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. */ @@ -174,21 +176,18 @@ fun ChartOverlay( fun HorizontalLinesOverlay( modifier: Modifier, lineColors: List, - minValue: Float, - maxValue: Float, ) { - val range = maxValue - minValue - val verticalSpacing = range / LINE_LIMIT + /* 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 = minValue + var lineY = 0f for (i in 0..LINE_LIMIT) { - val ratio = (lineY - minValue) / range + val ratio = lineY / MAX_PERCENT_VALUE val y = height - (ratio * height) drawLine( start = Offset(lineStart, y), @@ -322,11 +321,7 @@ fun TimeLabels( oldest: Int, newest: Int ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { + Row { Text( text = DATE_TIME_FORMAT.format(oldest * MS_PER_SEC), modifier = Modifier.wrapContentWidth(), @@ -350,7 +345,11 @@ fun TimeLabels( * @param promptInfoDialog Executes when the user presses the info icon. */ @Composable -fun Legend(legendData: List, promptInfoDialog: () -> Unit) { +fun Legend( + legendData: List, + displayInfoIcon: Boolean = true, + promptInfoDialog: () -> Unit = {} +) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -367,13 +366,14 @@ fun Legend(legendData: List, promptInfoDialog: () -> Unit) { Spacer(modifier = Modifier.weight(1f)) } } - Spacer(modifier = Modifier.width(4.dp)) - - Icon( - imageVector = Icons.Default.Info, - modifier = Modifier.clickable { promptInfoDialog() }, - contentDescription = stringResource(R.string.info) - ) + 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)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt index 77c15bd8..01f4c380 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -64,22 +64,21 @@ import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.BatteryInfo 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.components.CommonCharts.MAX_PERCENT_VALUE import com.geeksville.mesh.ui.theme.Orange import com.geeksville.mesh.util.GraphUtil import com.geeksville.mesh.util.GraphUtil.plotPoint import com.geeksville.mesh.util.GraphUtil.createPath -private val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) -private const val MAX_PERCENT_VALUE = 100f -private enum class Device { - BATTERY, - CH_UTIL, - AIR_UTIL +private enum class Device(val color: Color) { + BATTERY(Color.Green), + CH_UTIL(Color.Magenta), + AIR_UTIL(Color.Cyan) } private val LEGEND_DATA = listOf( - LegendData(nameRes = R.string.battery, color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], isLine = true), - LegendData(nameRes = R.string.channel_utilization, color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal]), - LegendData(nameRes = R.string.air_utilization, color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal]), + LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true), + LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color), + LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color), ) @Composable @@ -112,11 +111,12 @@ fun DeviceMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } /* Device Metric Cards */ @@ -180,8 +180,6 @@ private fun DeviceMetricsChart( HorizontalLinesOverlay( modifier.width(dp), lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor), - minValue = 0f, - maxValue = 100f ) TimeAxisOverlay( @@ -206,7 +204,7 @@ private fun DeviceMetricsChart( /* Channel Utilization */ plotPoint( drawContext = drawContext, - color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], + color = Device.CH_UTIL.color, x = x, value = telemetry.deviceMetrics.channelUtilization, divisor = MAX_PERCENT_VALUE @@ -215,7 +213,7 @@ private fun DeviceMetricsChart( /* Air Utilization Transmit */ plotPoint( drawContext = drawContext, - color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], + color = Device.AIR_UTIL.color, x = x, value = telemetry.deviceMetrics.airUtilTx, divisor = MAX_PERCENT_VALUE @@ -242,7 +240,7 @@ private fun DeviceMetricsChart( } drawPath( path = path, - color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], + color = Device.BATTERY.color, style = Stroke( width = GraphUtil.RADIUS, cap = StrokeCap.Round @@ -260,7 +258,7 @@ private fun DeviceMetricsChart( } Spacer(modifier = Modifier.height(16.dp)) - Legend(legendData = LEGEND_DATA, promptInfoDialog) + Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } 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 bf9254c8..9e81206e 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 @@ -61,30 +61,30 @@ import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.copy import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT -private val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue, Color.Green) -private enum class Environment { - TEMPERATURE, - HUMIDITY, - IAQ +private enum class Environment(val color: Color) { + TEMPERATURE(Color.Red), + HUMIDITY(Color.Blue), + IAQ(Color.Green) } private val LEGEND_DATA = listOf( LegendData( nameRes = R.string.temperature, - color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + color = Environment.TEMPERATURE.color, isLine = true ), LegendData( nameRes = R.string.humidity, - color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + color = Environment.HUMIDITY.color, isLine = true ), LegendData( nameRes = R.string.iaq, - color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + color = Environment.IAQ.color, isLine = true ), ) @@ -137,11 +137,12 @@ fun EnvironmentMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } /* Environment Metric Cards */ @@ -178,13 +179,13 @@ private fun EnvironmentMetricsChart( val graphColor = MaterialTheme.colors.onSurface val transparentTemperatureColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal].copy(alpha = 0.5f) + Environment.TEMPERATURE.color.copy(alpha = 0.5f) } val transparentHumidityColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal].copy(alpha = 0.5f) + Environment.HUMIDITY.color.copy(alpha = 0.5f) } val transparentIAQColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal].copy(alpha = 0.5f) + Environment.IAQ.color.copy(alpha = 0.5f) } val spacing = X_AXIS_SPACING @@ -280,7 +281,7 @@ private fun EnvironmentMetricsChart( drawPath( path = temperaturePath, - color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + color = Environment.TEMPERATURE.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -333,7 +334,7 @@ private fun EnvironmentMetricsChart( drawPath( path = humidityPath, - color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + color = Environment.HUMIDITY.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -388,7 +389,7 @@ private fun EnvironmentMetricsChart( drawPath( path = iaqPath, - color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + color = Environment.IAQ.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -399,7 +400,7 @@ private fun EnvironmentMetricsChart( Spacer(modifier = Modifier.height(16.dp)) - Legend(LEGEND_DATA, promptInfoDialog) + Legend(LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt new file mode 100644 index 00000000..d98a30d4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt @@ -0,0 +1,371 @@ +/* + * 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.ui.components + +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.material.Card +import androidx.compose.material.Surface +import androidx.compose.material.MaterialTheme +import androidx.compose.material.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.LocalConfiguration +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.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.Telemetry +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.util.GraphUtil +import com.geeksville.mesh.util.GraphUtil.createPath + +@Suppress("MagicNumber") +private enum class Power(val color: Color, val min: Float, val max: Float) { + CURRENT(Color(75, 119, 190), -500f, 500f), + VOLTAGE(Color.Red, 0f, 20f); + /** + * 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 val LEGEND_DATA = listOf( + LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true), + LegendData(nameRes = R.string.voltage, color = Power.VOLTAGE.color, isLine = true), +) + +@Composable +fun PowerMetricsScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val selectedTimeFrame by viewModel.timeFrame.collectAsState() + var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } + val data = state.powerMetricsFiltered(selectedTimeFrame) + + Column { + + 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, + 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 + + TimeLabels( + oldest = oldest.time, + newest = newest.time + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val currentDiff = Power.CURRENT.difference() + val voltageDiff = Power.VOLTAGE.difference() + + val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp + val dp by remember(key1 = selectedTime) { + mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong())) + } + + Row { + YAxisLabels( + modifier = modifier.weight(weight = .1f), + 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) / voltageDiff + val y = height - (ratio * height) + return@createPath y + } + drawPath( + path = path, + color = Power.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 = .1f), + Power.VOLTAGE.color, + minValue = Power.VOLTAGE.min, + maxValue = Power.VOLTAGE.max, + ) + } + + 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.button.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.button.fontSize + ) + Text( + text = "%.2fV".format(voltage), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + Text( + text = "%.1fmA".format(current), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +/** + * Retrieves the appropriate voltage depending on `channelSelected`. + */ +private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float { + return 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 { + return when (channelSelected) { + PowerChannel.ONE -> telemetry.powerMetrics.ch1Current + PowerChannel.TWO -> telemetry.powerMetrics.ch2Current + PowerChannel.THREE -> telemetry.powerMetrics.ch3Current + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt index 6a62ca1e..927d1233 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -63,20 +63,18 @@ import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.util.GraphUtil.plotPoint -private val METRICS_COLORS = listOf(Color.Green, Color.Blue) - @Suppress("MagicNumber") -private enum class Metric(val min: Float, val max: Float) { - SNR(-20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */ - RSSI(-140f, -20f); +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 val LEGEND_DATA = listOf( - LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]), - LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]) + LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color), + LegendData(nameRes = R.string.snr, color = Metric.SNR.color) ) @Composable @@ -109,11 +107,12 @@ fun SignalMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } LazyColumn( @@ -166,7 +165,7 @@ private fun SignalMetricsChart( Row { YAxisLabels( modifier = modifier.weight(weight = .1f), - METRICS_COLORS[Metric.RSSI.ordinal], + Metric.RSSI.color, minValue = Metric.RSSI.min, maxValue = Metric.RSSI.max, ) @@ -179,8 +178,6 @@ private fun SignalMetricsChart( HorizontalLinesOverlay( modifier.width(dp), lineColors = List(size = 5) { graphColor }, - minValue = Metric.SNR.min, - maxValue = Metric.SNR.max ) TimeAxisOverlay( @@ -202,7 +199,7 @@ private fun SignalMetricsChart( /* SNR */ plotPoint( drawContext = drawContext, - color = METRICS_COLORS[Metric.SNR.ordinal], + color = Metric.SNR.color, x = x, value = packet.rxSnr - Metric.SNR.min, divisor = snrDiff @@ -211,7 +208,7 @@ private fun SignalMetricsChart( /* RSSI */ plotPoint( drawContext = drawContext, - color = METRICS_COLORS[Metric.RSSI.ordinal], + color = Metric.RSSI.color, x = x, value = packet.rxRssi - Metric.RSSI.min, divisor = rssiDiff @@ -221,7 +218,7 @@ private fun SignalMetricsChart( } YAxisLabels( modifier = modifier.weight(weight = .1f), - METRICS_COLORS[Metric.SNR.ordinal], + Metric.SNR.color, minValue = Metric.SNR.min, maxValue = Metric.SNR.max, ) @@ -229,7 +226,7 @@ private fun SignalMetricsChart( Spacer(modifier = Modifier.height(16.dp)) - Legend(legendData = LEGEND_DATA, promptInfoDialog) + Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt similarity index 88% rename from app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt rename to app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt index 21dc65b6..fdd5969e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt @@ -94,20 +94,22 @@ private const val PRESSED_UNSELECTED_ALPHA = .6f private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp) /** - * Provides the user with a set of time options they can choose from that controls - * the time frame the data being plotted was received. + * Provides the user with a set of options they can choose from. + * * (Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117) */ @Composable -fun MetricsTimeSelector( - selectedTime: TimeFrame, - onOptionSelected: (TimeFrame) -> Unit, +fun SlidingSelector( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, modifier: Modifier = Modifier, - content: @Composable (TimeFrame) -> Unit + content: @Composable (T) -> Unit ) { - val state = remember { TimeSelectorState() } - state.selectedOption = state.options.indexOf(selectedTime) - state.onOptionSelected = { onOptionSelected(state.options[it]) } + val state = remember { SelectorState() } + state.optionCount = options.size + state.selectedOption = options.indexOf(selectedOption) + state.onOptionSelected = { onOptionSelected(options[it]) } /* Animate between whole-number indices so we don't need to do pixel calculations. */ val selectedIndexOffset by animateFloatAsState(state.selectedOption.toFloat(), label = "Selected Index Offset") @@ -116,7 +118,7 @@ fun MetricsTimeSelector( content = { SelectedIndicator(state) Dividers(state) - TimeOptions(state, content) + Options(state, options, content) }, modifier = modifier .fillMaxWidth() @@ -133,7 +135,7 @@ fun MetricsTimeSelector( /* Measure the indicator and dividers to be the right size. */ val indicatorPlaceable = indicatorMeasurable.measure( Constraints.fixed( - width = optionsPlaceable.width / state.options.size, + width = optionsPlaceable.width / options.size, height = optionsPlaceable.height ) ) @@ -146,7 +148,7 @@ fun MetricsTimeSelector( ) layout(optionsPlaceable.width, optionsPlaceable.height) { - val optionWidth = optionsPlaceable.width / state.options.size + val optionWidth = optionsPlaceable.width / options.size /* Place the indicator first so that it's below the option labels. */ indicatorPlaceable.placeRelative( @@ -160,18 +162,18 @@ fun MetricsTimeSelector( } /** - * Visual representation of the time option the user may select. + * Visual representation of the option the user may select. */ @Composable -fun TimeLabel(text: String) { +fun OptionLabel(text: String) { Text(text, maxLines = 1, overflow = Ellipsis) } /** - * Draws the selected indicator on the [MetricsTimeSelector] track. + * Draws the selected indicator on the [SlidingSelector] track. */ @Composable -private fun SelectedIndicator(state: TimeSelectorState) { +private fun SelectedIndicator(state: SelectorState) { Box( Modifier .then( @@ -186,18 +188,18 @@ private fun SelectedIndicator(state: TimeSelectorState) { } /** - * Draws dividers between [TimeLabel]s. + * Draws dividers between [OptionLabel]s. */ @Composable -private fun Dividers(state: TimeSelectorState) { +private fun Dividers(state: SelectorState) { /* Animate each divider independently. */ - val alphas = (0 until state.options.size).map { i -> + val alphas = (0 until state.optionCount).map { i -> val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers") } Canvas(Modifier.fillMaxSize()) { - val optionWidth = size.width / state.options.size + val optionWidth = size.width / state.optionCount val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING alphas.forEachIndexed { i, alpha -> @@ -213,12 +215,13 @@ private fun Dividers(state: TimeSelectorState) { } /** - * Draws the time options available to the user. + * Draws the options available to the user. */ @Composable -private fun TimeOptions( - state: TimeSelectorState, - content: @Composable (TimeFrame) -> Unit +private fun Options( + state: SelectorState, + options: List, + content: @Composable (T) -> Unit ) { CompositionLocalProvider( LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium) @@ -229,7 +232,7 @@ private fun TimeOptions( .fillMaxWidth() .selectableGroup() ) { - state.options.forEachIndexed { i, timeFrame -> + options.forEachIndexed { i, timeFrame -> val isSelected = i == state.selectedOption val isPressed = i == state.pressedOption @@ -267,10 +270,10 @@ private fun TimeOptions( } /** - * Contains and handles the state necessary to present the [MetricsTimeSelector] to the user. + * Contains and handles the state necessary to present the [SlidingSelector] to the user. */ -private class TimeSelectorState { - val options = TimeFrame.entries.toTypedArray() +private class SelectorState { + var optionCount by mutableIntStateOf(0) var selectedOption by mutableIntStateOf(0) var onOptionSelected: (Int) -> Unit by mutableStateOf({}) var pressedOption by mutableIntStateOf(NO_OPTION_INDEX) @@ -317,7 +320,7 @@ private class TimeSelectorState { this.transformOrigin = TransformOrigin( pivotFractionX = when (option) { 0 -> 0f - options.size - 1 -> 1f + optionCount - 1 -> 1f else -> .5f }, pivotFractionY = .5f @@ -326,7 +329,7 @@ private class TimeSelectorState { /* But should still move inwards to keep the pressed padding consistent with top and bottom. */ this.translationX = when (option) { 0 -> xOffset.toPx() - options.size - 1 -> -xOffset.toPx() + optionCount - 1 -> -xOffset.toPx() else -> 0f } } @@ -336,14 +339,14 @@ private class TimeSelectorState { * A [Modifier] that will listen for touch gestures and update the selected and pressed properties * of this state appropriately. */ - val inputModifier = Modifier.pointerInput(options.size) { - val optionWidth = size.width / options.size + val inputModifier = Modifier.pointerInput(optionCount) { + val optionWidth = size.width / optionCount /* Helper to calculate which option an event occurred in. */ fun optionIndex(change: PointerInputChange): Int = - ((change.position.x / size.width.toFloat()) * options.size) + ((change.position.x / size.width.toFloat()) * optionCount) .toInt() - .coerceIn(0, options.size - 1) + .coerceIn(0, optionCount - 1) awaitEachGesture { val down = awaitFirstDown() @@ -401,17 +404,18 @@ private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rec @Preview @Composable -fun MetricsTimeSelectorPreview() { +fun SlidingSelectorPreview() { MaterialTheme { Surface { Column(Modifier.padding(8.dp)) { var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedOption, onOptionSelected = { selectedOption = it } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } } } diff --git a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt index 95b737b9..b2ce995b 100644 --- a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt +++ b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt @@ -60,6 +60,7 @@ object GraphUtil { * @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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39c8048e..bb6b7fd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -313,4 +313,10 @@ Unknown Age Copy Alert Bell Character! + Power Metrics Log + Channel 1 + Channel 2 + Channel 3 + Current + Voltage