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>
pull/1522/head^2
Robert-0410 2025-02-15 20:37:05 -08:00 zatwierdzone przez GitHub
rodzic 24abd1ac4a
commit b067a0c0b3
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
12 zmienionych plików z 514 dodań i 112 usunięć

Wyświetl plik

@ -74,6 +74,7 @@ data class MetricsState(
val deviceMetrics: List<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val powerMetrics: List<Telemetry> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshPacket> = emptyList(),
val positionLogs: List<Position> = 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<Telemetry> {
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)

Wyświetl plik

@ -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<MetricsViewModel>(parentEntry),
)
}
composable<Route.PowerMetrics> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
PowerMetricsScreen(
viewModel = hiltViewModel<MetricsViewModel>(parentEntry),
)
}
composable<Route.TracerouteLog> {
val parentEntry = remember { navController.getBackStackEntry<Route.NodeDetail>() }
TracerouteLogScreen(

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<Color>,
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<LegendData>, promptInfoDialog: () -> Unit) {
fun Legend(
legendData: List<LegendData>,
displayInfoIcon: Boolean = true,
promptInfoDialog: () -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@ -367,13 +366,14 @@ fun Legend(legendData: List<LegendData>, 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))
}

Wyświetl plik

@ -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))
}

Wyświetl plik

@ -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))
}

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<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
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
}
}

Wyświetl plik

@ -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))
}

Wyświetl plik

@ -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 <T : Any> SlidingSelector(
options: List<T>,
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 <T> Options(
state: SelectorState,
options: List<T>,
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))
}
}
}

Wyświetl plik

@ -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<Telemetry>,

Wyświetl plik

@ -313,4 +313,10 @@
<string name="unknown_age">Unknown Age</string>
<string name="copy">Copy</string>
<string name="alert_bell_text">Alert Bell Character!</string>
<string name="power_metrics_log">Power Metrics Log</string>
<string name="channel_1">Channel 1</string>
<string name="channel_2">Channel 2</string>
<string name="channel_3">Channel 3</string>
<string name="current">Current</string>
<string name="voltage">Voltage</string>
</resources>