diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index b0cb1c6ac..b78ff9164 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -1,6 +1,7 @@ package com.geeksville.mesh.database import com.geeksville.mesh.Portnums +import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.database.dao.MeshLogDao import com.geeksville.mesh.database.entity.MeshLog @@ -29,6 +30,9 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { Telemetry.parseFrom(log.fromRadio.packet.decoded.payload) }.getOrNull() + private fun parseMeshPacket(log: MeshLog): MeshPacket? = + runCatching { log.meshPacket }.getOrNull() + @OptIn(ExperimentalCoroutinesApi::class) fun getTelemetryFrom(nodeNum: Int): Flow> = meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) @@ -36,6 +40,13 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } .flowOn(Dispatchers.IO) + @OptIn(ExperimentalCoroutinesApi::class) + fun getMeshPacketsFrom(nodeNum: Int): Flow> = + meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) + .distinctUntilChanged() + .mapLatest { list -> list.mapNotNull(::parseMeshPacket) } + .flowOn(Dispatchers.IO) + suspend fun insert(log: MeshLog) = withContext(Dispatchers.IO) { meshLogDao.insert(log) } 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 c58082b94..5087e0ddb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.repository.datastore.RadioConfigRepository @@ -17,10 +18,12 @@ import javax.inject.Inject data class MetricsState( val deviceMetrics: List = emptyList(), val environmentMetrics: List = emptyList(), + val signalMetrics: List = emptyList(), val environmentDisplayFahrenheit: Boolean = false, ) { fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() + fun hasSignalMetrics() = signalMetrics.isNotEmpty() companion object { val Empty = MetricsState() @@ -38,13 +41,15 @@ class MetricsViewModel @Inject constructor( val state = destNum.flatMapLatest { destNum -> combine( meshLogRepository.getTelemetryFrom(destNum), + meshLogRepository.getMeshPacketsFrom(destNum), radioConfigRepository.moduleConfigFlow, - ) { telemetry, config -> + ) { telemetry, meshPackets, config -> MetricsState( deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f }, + signalMetrics = meshPackets.filter { it.rxTime > 0 }, environmentDisplayFahrenheit = config.telemetry.environmentDisplayFahrenheit, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 87f2629ad..8027d481d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -65,6 +65,7 @@ import com.geeksville.mesh.moduleConfig import com.geeksville.mesh.service.MeshService.ConnectionState import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen +import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList import com.geeksville.mesh.ui.components.config.AudioConfigItemList import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList @@ -296,6 +297,9 @@ fun NavGraph( metricsState.environmentDisplayFahrenheit, ) } + composable("SignalMetrics") { + SignalMetricsScreen(metricsState.signalMetrics) + } composable("RadioConfig") { RadioConfigScreen( node = node, diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt index 28f2c83f0..e390d2663 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.Numbers import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SignalCellularAlt import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Thermostat import androidx.compose.material.icons.filled.WaterDrop @@ -127,7 +128,7 @@ private fun NodeDetailsItemList( item { NavCard( - title = "Device Metrics Logs", + title = stringResource(R.string.device_metrics_logs), icon = Icons.Default.ChargingStation, enabled = metricsState.hasDeviceMetrics() ) { @@ -135,13 +136,21 @@ private fun NodeDetailsItemList( } NavCard( - title = "Environment Metrics Logs", + title = stringResource(R.string.env_metrics_logs), icon = Icons.Default.Thermostat, enabled = metricsState.hasEnvironmentMetrics() ) { onNavigate("EnvironmentMetrics") } + NavCard( + title = stringResource(R.string.sig_metrics_logs), + icon = Icons.Default.SignalCellularAlt, + enabled = metricsState.hasSignalMetrics() + ) { + onNavigate("SignalMetrics") + } + NavCard( title = "Remote Administration", icon = Icons.Default.Settings, 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 ee56eabf9..45d5e60cb 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 @@ -3,15 +3,24 @@ package com.geeksville.mesh.ui.components import android.graphics.Paint import android.graphics.Typeface import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,23 +34,30 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.geeksville.mesh.R +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.TIME_FORMAT +import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING import java.text.DateFormat - object CommonCharts { val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) - const val LEFT_CHART_SPACING = 8f + const val X_AXIS_SPACING = 8f + const val LEFT_LABEL_SPACING = 36 const val MS_PER_SEC = 1000.0f + const val LINE_LIMIT = 4 + const val TEXT_PAINT_ALPHA = 192 } -private const val LINE_LIMIT = 4 -private const val TEXT_PAINT_ALPHA = 192 private const val LINE_ON = 10f private const val LINE_OFF = 20f +data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false) @Composable fun ChartHeader(amount: Int) { @@ -61,21 +77,26 @@ fun ChartHeader(amount: Int) { /** * Draws chart lines and labels with respect to the Y-axis range; defined by (`maxValue` - `minValue`). - * Assumes `lineColors` is a list of 5 `Color`s with index 0 being the lowest line on the chart. + * + * @param labelColor The color to be used for the Y labels. + * @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. */ @Composable fun ChartOverlay( modifier: Modifier, - graphColor: Color, + labelColor: Color, lineColors: List, minValue: Float, - maxValue: Float + maxValue: Float, + leaveSpace: Boolean = false ) { val range = maxValue - minValue val verticalSpacing = range / LINE_LIMIT val density = LocalDensity.current Canvas(modifier = modifier) { + val lineStart = if (leaveSpace) LEFT_LABEL_SPACING.dp.toPx() else 0f val height = size.height val width = size.width - 28.dp.toPx() @@ -85,7 +106,7 @@ fun ChartOverlay( val ratio = (lineY - minValue) / range val y = height - (ratio * height) drawLine( - start = Offset(0f, y), + start = Offset(lineStart, y), end = Offset(width, y), color = lineColors[i], strokeWidth = 1.dp.toPx(), @@ -98,7 +119,7 @@ fun ChartOverlay( /* Y Labels */ val textPaint = Paint().apply { - color = graphColor.toArgb() + color = labelColor.toArgb() textAlign = Paint.Align.LEFT textSize = density.run { 12.dp.toPx() } typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) @@ -127,41 +148,110 @@ fun ChartOverlay( */ @Composable fun TimeLabels( - modifier: Modifier, - graphColor: Color, oldest: Float, newest: Float ) { - val density = LocalDensity.current - Canvas(modifier = modifier) { - - val textPaint = Paint().apply { - color = graphColor.toArgb() - textAlign = Paint.Align.LEFT - textSize = density.run { 12.dp.toPx() } - typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) - alpha = TEXT_PAINT_ALPHA - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = TIME_FORMAT.format(oldest), + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = TIME_FORMAT.format(newest), + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = 12.sp + ) + } +} - drawContext.canvas.nativeCanvas.apply { - drawText( - TIME_FORMAT.format(oldest), - 8.dp.toPx(), - 12.dp.toPx(), - textPaint - ) - drawText( - TIME_FORMAT.format(newest), - size.width - 140.dp.toPx(), - 12.dp.toPx(), - textPaint +/** + * Creates the legend that identifies the colors used for the graph. + * + * @param legendData A list containing the `LegendData` to build the labels. + * @param promptInfoDialog Executes when the user presses the info icon. + */ +@Composable +fun Legend(legendData: List, promptInfoDialog: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(1f)) + for (data in legendData) { + LegendLabel( + text = stringResource(data.nameRes), + color = data.color, + isLine = data.isLine ) + + Spacer(modifier = Modifier.width(4.dp)) } + + Icon( + imageVector = Icons.Default.Info, + modifier = Modifier.clickable { promptInfoDialog() }, + contentDescription = stringResource(R.string.info) + ) + + Spacer(modifier = Modifier.weight(1f)) } } +/** + * Displays a dialog with information about the legend items. + * + * @param pairedRes A list of `Pair`s containing (term, definition). + * @param onDismiss Executes when the user presses the close button. + */ +@Composable +fun LegendInfoDialog(pairedRes: List>, onDismiss: () -> Unit) { + AlertDialog( + title = { + Text( + text = stringResource(R.string.info), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + }, + text = { + Column { + for (pair in pairedRes) { + Text( + text = stringResource(pair.first), + style = TextStyle(fontWeight = FontWeight.Bold), + textDecoration = TextDecoration.Underline + ) + Text( + text = stringResource(pair.second), + style = TextStyle.Default, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background + ) +} + @Composable -fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { +private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { Canvas( modifier = Modifier.size(4.dp) ) { 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 fdcb0c59a..f1aed3412 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 @@ -1,7 +1,6 @@ package com.geeksville.mesh.ui.components import androidx.compose.foundation.Canvas -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,20 +11,13 @@ 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.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.AlertDialog import androidx.compose.material.Card -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,20 +33,27 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.ui.BatteryInfo -import com.geeksville.mesh.ui.components.CommonCharts.LEFT_CHART_SPACING +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.TIME_FORMAT import com.geeksville.mesh.ui.theme.Orange 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 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]), +) @Composable fun DeviceMetricsScreen(telemetries: List) { @@ -64,7 +63,13 @@ fun DeviceMetricsScreen(telemetries: List) { Column { if (displayInfoDialog) { - DeviceInfoDialog { displayInfoDialog = false } + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.channel_utilization, R.string.ch_util_definition), + Pair(R.string.air_utilization, R.string.air_util_definition) + ), + onDismiss = { displayInfoDialog = false } + ) } DeviceMetricsChart( @@ -94,10 +99,15 @@ private fun DeviceMetricsChart( ChartHeader(amount = telemetries.size) if (telemetries.isEmpty()) return + TimeLabels( + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) + Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - val spacing = LEFT_CHART_SPACING + val spacing = X_AXIS_SPACING Box(contentAlignment = Alignment.TopStart) { @@ -130,28 +140,28 @@ private fun DeviceMetricsChart( val rightRatio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE val x1 = spacing + i * spacePerEntry - val y1 = height - spacing - (leftRatio * height) + val y1 = height - (leftRatio * height) /* Channel Utilization */ val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE - val yChUtil = height - spacing - (chUtilRatio * height) + val yChUtil = height - (chUtilRatio * height) drawCircle( - color = DEVICE_METRICS_COLORS[1], + color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], radius = dataPointRadius, center = Offset(x1, yChUtil) ) /* Air Utilization Transmit */ val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE - val yAirUtil = height - spacing - (airUtilRatio * height) + val yAirUtil = height - (airUtilRatio * height) drawCircle( - color = DEVICE_METRICS_COLORS[2], + color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], radius = dataPointRadius, center = Offset(x1, yAirUtil) ) val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - spacing - (rightRatio * height) + val y2 = height - (rightRatio * height) if (i == 0) { moveTo(x1, y1) } @@ -165,24 +175,17 @@ private fun DeviceMetricsChart( /* Battery Line */ drawPath( path = strokePath, - color = DEVICE_METRICS_COLORS[0], + color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], style = Stroke( width = dataPointRadius, cap = StrokeCap.Round ) ) } - - TimeLabels( - modifier = modifier, - graphColor = graphColor, - oldest = telemetries.first().time * MS_PER_SEC, - newest = telemetries.last().time * MS_PER_SEC - ) } Spacer(modifier = Modifier.height(16.dp)) - DeviceLegend(promptInfoDialog) + Legend(legendData = LEGEND_DATA, promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } @@ -243,86 +246,3 @@ private fun DeviceMetricsCard(telemetry: Telemetry) { } } } - -@Composable -private fun DeviceLegend(promptInfoDialog: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.weight(1f)) - - LegendLabel(text = stringResource(R.string.battery), color = DEVICE_METRICS_COLORS[0], isLine = true) - - Spacer(modifier = Modifier.width(4.dp)) - - LegendLabel(text = stringResource(R.string.channel_utilization), color = DEVICE_METRICS_COLORS[1]) - - Spacer(modifier = Modifier.width(4.dp)) - - LegendLabel(text = stringResource(R.string.air_utilization), color = DEVICE_METRICS_COLORS[2]) - - 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)) - } -} - -@Composable -private fun DeviceInfoDialog(onDismiss: () -> Unit) { - AlertDialog( - title = { - Text( - text = stringResource(R.string.info), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - }, - text = { - Column { - Text( - text = stringResource(R.string.channel_utilization), - style = TextStyle(fontWeight = FontWeight.Bold), - textDecoration = TextDecoration.Underline - ) - Text( - text = stringResource(R.string.ch_util_definition), - style = TextStyle.Default, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.air_utilization), - style = TextStyle(fontWeight = FontWeight.Bold), - textDecoration = TextDecoration.Underline - ) - Text( - text = stringResource(R.string.air_util_definition), - style = TextStyle.Default - ) - } - }, - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.close)) - } - }, - shape = RoundedCornerShape(16.dp), - backgroundColor = MaterialTheme.colors.background - ) -} - -@Preview -@Composable -private fun DeviceInfoDialogPreview() { - DeviceInfoDialog {} -} 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 23010b166..840dafcd7 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 @@ -20,7 +20,10 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -37,12 +40,33 @@ import androidx.compose.ui.unit.dp import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.copy -import com.geeksville.mesh.ui.components.CommonCharts.LEFT_CHART_SPACING +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.TIME_FORMAT - private val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue, Color.Green) +private enum class Environment { + TEMPERATURE, + HUMIDITY, + IAQ +} +private val LEGEND_DATA = listOf( + LegendData( + nameRes = R.string.temperature, + color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + isLine = true + ), + LegendData( + nameRes = R.string.humidity, + color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + isLine = true + ), + LegendData( + nameRes = R.string.iaq, + color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + isLine = true + ), +) @Composable fun EnvironmentMetricsScreen(telemetries: List, environmentDisplayFahrenheit: Boolean) { @@ -65,12 +89,25 @@ fun EnvironmentMetricsScreen(telemetries: List, environmentDisplayFah telemetries } + var displayInfoDialog by remember { mutableStateOf(false) } + Column { + + if (displayInfoDialog) { + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.iaq, R.string.iaq_definition) + ), + onDismiss = { displayInfoDialog = false } + ) + } + EnvironmentMetricsChart( modifier = Modifier .fillMaxWidth() .fillMaxHeight(fraction = 0.33f), telemetries = processedTelemetries.reversed(), + promptInfoDialog = { displayInfoDialog = true } ) /* Environment Metric Cards */ @@ -89,19 +126,33 @@ fun EnvironmentMetricsScreen(telemetries: List, environmentDisplayFah @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List) { +private fun EnvironmentMetricsChart( + modifier: Modifier = Modifier, + telemetries: List, + promptInfoDialog: () -> Unit +) { ChartHeader(amount = telemetries.size) if (telemetries.isEmpty()) { return } + TimeLabels( + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - val transparentTemperatureColor = remember { ENVIRONMENT_METRICS_COLORS[0].copy(alpha = 0.5f) } - val transparentHumidityColor = remember { ENVIRONMENT_METRICS_COLORS[1].copy(alpha = 0.5f) } - val transparentIAQColor = remember { ENVIRONMENT_METRICS_COLORS[2].copy(alpha = 0.5f) } - val spacing = LEFT_CHART_SPACING + val transparentTemperatureColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal].copy(alpha = 0.5f) + } + val transparentHumidityColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal].copy(alpha = 0.5f) + } + val transparentIAQColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal].copy(alpha = 0.5f) + } + val spacing = X_AXIS_SPACING /* Since both temperature and humidity are being plotted we need a combined min and max. */ val (minTemp, maxTemp) = remember(key1 = telemetries) { @@ -137,7 +188,7 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: Box(contentAlignment = Alignment.TopStart) { ChartOverlay( modifier = modifier, - graphColor = graphColor, + labelColor = graphColor, lineColors = List(size = 5) { graphColor }, minValue = min, maxValue = max @@ -160,10 +211,10 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val rightRatio = (nextEnvMetrics.temperature - min) / diff val x1 = spacing + i * spacePerEntry - val y1 = height - spacing - (leftRatio * height) + val y1 = height - (leftRatio * height) val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - spacing - (rightRatio * height) + val y2 = height - (rightRatio * height) if (i == 0) { moveTo(x1, y1) } @@ -177,8 +228,8 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val fillPath = android.graphics.Path(temperaturePath.asAndroidPath()) .asComposePath() .apply { - lineTo(lastTempX, height - spacing) - lineTo(spacing, height - spacing) + lineTo(lastTempX, height) + lineTo(spacing, height) close() } @@ -189,13 +240,13 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: transparentTemperatureColor, Color.Transparent ), - endY = height - spacing + endY = height ), ) drawPath( path = temperaturePath, - color = ENVIRONMENT_METRICS_COLORS[0], + color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -213,10 +264,10 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val rightRatio = (nextEnvMetrics.relativeHumidity - min) / diff val x1 = spacing + i * spacePerEntry - val y1 = height - spacing - (leftRatio * height) + val y1 = height - (leftRatio * height) val x2 = spacing + (i + 1) * spacePerEntry - val y2 = height - spacing - (rightRatio * height) + val y2 = height - (rightRatio * height) if (i == 0) { moveTo(x1, y1) } @@ -230,8 +281,8 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val fillHumidityPath = android.graphics.Path(humidityPath.asAndroidPath()) .asComposePath() .apply { - lineTo(lastHumidityX, height - spacing) - lineTo(spacing, height - spacing) + lineTo(lastHumidityX, height) + lineTo(spacing, height) close() } @@ -242,13 +293,13 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: transparentHumidityColor, Color.Transparent ), - endY = height - spacing + endY = height ), ) drawPath( path = humidityPath, - color = ENVIRONMENT_METRICS_COLORS[1], + color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -266,11 +317,10 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val rightRatio = (nextEnvMetrics.iaq - min) / diff val x1 = spacing + i * spacePerEntry - val y1 = height - spacing - (leftRatio * height) + val y1 = height - (leftRatio * height) val x2 = spacing + (i + 1) * spacePerEntry - - val y2 = height - spacing - (rightRatio * height) + val y2 = height - (rightRatio * height) if (i == 0) { moveTo(x1, y1) } @@ -287,8 +337,8 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: val fillIaqPath = android.graphics.Path(iaqPath.asAndroidPath()) .asComposePath() .apply { - lineTo(lastIaqX, height - spacing) - lineTo(spacing, height - spacing) + lineTo(lastIaqX, height) + lineTo(spacing, height) close() } drawPath( @@ -298,30 +348,24 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: transparentIAQColor, Color.Transparent ), - endY = height - spacing + endY = height ), ) drawPath( path = iaqPath, - color = ENVIRONMENT_METRICS_COLORS[2], + color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round ) ) } - TimeLabels( - modifier = modifier, - graphColor = graphColor, - oldest = telemetries.first().time * MS_PER_SEC, - newest = telemetries.last().time * MS_PER_SEC - ) } Spacer(modifier = Modifier.height(16.dp)) - EnvironmentLegend() + Legend(LEGEND_DATA, promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } @@ -413,30 +457,3 @@ private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahre } } } - -@Composable -private fun EnvironmentLegend() { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.weight(1f)) - - LegendLabel(text = stringResource(R.string.temperature), color = ENVIRONMENT_METRICS_COLORS[0], isLine = true) - - Spacer(modifier = Modifier.width(8.dp)) - - LegendLabel(text = stringResource(R.string.humidity), color = ENVIRONMENT_METRICS_COLORS[1], isLine = true) - - Spacer(modifier = Modifier.width(8.dp)) - - LegendLabel( - text = stringResource(R.string.iaq), - color = ENVIRONMENT_METRICS_COLORS[2], - isLine = true - ) - - Spacer(modifier = Modifier.weight(1f)) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt b/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt new file mode 100644 index 000000000..6f6f42629 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt @@ -0,0 +1,107 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SignalCellular4Bar +import androidx.compose.material.icons.filled.SignalCellularAlt +import androidx.compose.material.icons.filled.SignalCellularAlt1Bar +import androidx.compose.material.icons.filled.SignalCellularAlt2Bar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +private const val SNR_GOOD_THRESHOLD = -7f +private const val SNR_FAIR_THRESHOLD = -15f + +private const val RSSI_GOOD_THRESHOLD = -115 +private const val RSSI_FAIR_THRESHOLD = -126 + +private enum class Quality( + val nameRes: Int, + val imageVector: ImageVector, + val color: Color +) { + NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, Color.Red), + BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, Color(red = 247, green = 147, blue = 26)), + FAIR(R.string.fair, Icons.Default.SignalCellularAlt, Color(red = 255, green = 230, blue = 0)), + GOOD(R.string.good, Icons.Default.SignalCellular4Bar, Color.Green) +} + +@Composable +fun Snr(snr: Float) { + val color: Color = if (snr > SNR_GOOD_THRESHOLD) { + Quality.GOOD.color + } else if (snr > SNR_FAIR_THRESHOLD) { + Quality.FAIR.color + } else { + Quality.BAD.color + } + + Text( + text = "%s %.2fdB".format( + stringResource(id = R.string.snr), + snr + ), + color = color, + fontSize = MaterialTheme.typography.button.fontSize + ) +} + +@Composable +fun Rssi(rssi: Int) { + val color: Color = if (rssi > RSSI_GOOD_THRESHOLD) { + Quality.GOOD.color + } else if (rssi > RSSI_FAIR_THRESHOLD) { + Quality.FAIR.color + } else { + Quality.BAD.color + } + Text( + text = "%s %ddB".format( + stringResource(id = R.string.rssi), + rssi + ), + color = color, + fontSize = MaterialTheme.typography.button.fontSize + ) +} + +@Composable +fun LoraSignalIndicator(snr: Float, rssi: Int) { + + val quality = determineSignalQuality(snr, rssi) + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + Icon( + imageVector = quality.imageVector, + contentDescription = stringResource(R.string.signal_quality), + tint = quality.color + ) + Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}") + } +} + +private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when { + snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD + snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR + snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR + snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE + else -> Quality.BAD +} 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 new file mode 100644 index 000000000..bc3b3d547 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -0,0 +1,275 @@ +package com.geeksville.mesh.ui.components + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +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.LEFT_LABEL_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT + +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); + /** + * Difference between the metrics `max` and `min` values. + */ + fun difference() = max - min +} +private val LEGEND_DATA = listOf( + LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]), + LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]) +) + +@Composable +fun SignalMetricsScreen(meshPackets: List) { + + var displayInfoDialog by remember { mutableStateOf(false) } + + Column { + + if (displayInfoDialog) { + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.snr, R.string.snr_definition), + Pair(R.string.rssi, R.string.rssi_definition) + ), + onDismiss = { displayInfoDialog = false } + ) + } + + SignalMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + meshPackets = meshPackets.reversed(), + promptInfoDialog = { displayInfoDialog = true } + ) + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(meshPackets) { meshPacket -> SignalMetricsCard(meshPacket) } + } + } +} + +@Composable +private fun SignalMetricsChart( + modifier: Modifier = Modifier, + meshPackets: List, + promptInfoDialog: () -> Unit +) { + + ChartHeader(amount = meshPackets.size) + if (meshPackets.isEmpty()) { + return + } + + TimeLabels( + oldest = meshPackets.first().rxTime * MS_PER_SEC, + newest = meshPackets.last().rxTime * MS_PER_SEC + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val snrDiff = Metric.SNR.difference() + val rssiDiff = Metric.RSSI.difference() + + Box(contentAlignment = Alignment.TopStart) { + + ChartOverlay( + modifier = modifier, + lineColors = List(size = 5) { graphColor }, + labelColor = METRICS_COLORS[Metric.SNR.ordinal], + minValue = Metric.SNR.min, + maxValue = Metric.SNR.max, + leaveSpace = true + ) + LeftYLabels(modifier = modifier, labelColor = METRICS_COLORS[Metric.RSSI.ordinal]) + + /* Plot SNR and RSSI */ + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + val spacing = LEFT_LABEL_SPACING.dp.toPx() + val spacePerEntry = (width - spacing) / meshPackets.size + + /* Plot */ + val dataPointRadius = 2.dp.toPx() + for ((i, packet) in meshPackets.withIndex()) { + + val x = spacing + i * spacePerEntry + + /* SNR */ + val snrRatio = (packet.rxSnr - Metric.SNR.min) / snrDiff + val ySNR = height - (snrRatio * height) + drawCircle( + color = METRICS_COLORS[Metric.SNR.ordinal], + radius = dataPointRadius, + center = Offset(x, ySNR) + ) + + /* RSSI */ + val rssiRatio = (packet.rxRssi - Metric.RSSI.min) / rssiDiff + val yRssi = height - (rssiRatio * height) + drawCircle( + color = METRICS_COLORS[Metric.RSSI.ordinal], + radius = dataPointRadius, + center = Offset(x, yRssi) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Legend(legendData = LEGEND_DATA, promptInfoDialog) + + Spacer(modifier = Modifier.height(16.dp)) +} + +/** + * Draws a set of Y labels on the left side of the graph. + * Currently only used for the RSSI labels. + */ +@Composable +private fun LeftYLabels( + modifier: Modifier, + labelColor: Color, +) { + val range = Metric.RSSI.difference() + val verticalSpacing = range / LINE_LIMIT + val density = LocalDensity.current + Canvas(modifier = modifier) { + + val height = size.height + + /* Y Labels */ + + val textPaint = Paint().apply { + color = labelColor.toArgb() + textAlign = Paint.Align.LEFT + textSize = density.run { 12.dp.toPx() } + typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) + alpha = TEXT_PAINT_ALPHA + } + drawContext.canvas.nativeCanvas.apply { + var label = Metric.RSSI.min + for (i in 0..LINE_LIMIT) { + val ratio = (label - Metric.RSSI.min) / range + val y = height - (ratio * height) + drawText( + "${label.toInt()}", + 4.dp.toPx(), + y + 4.dp.toPx(), + textPaint + ) + label += verticalSpacing + } + } + } +} + +@Composable +private fun SignalMetricsCard(meshPacket: MeshPacket) { + val time = meshPacket.rxTime * MS_PER_SEC + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface { + SelectionContainer { + Row( + modifier = Modifier.fillMaxWidth() + ) { + + /* Data */ + Box( + modifier = Modifier + .weight(weight = 5f) + .height(IntrinsicSize.Min) + ) { + Column( + modifier = Modifier + .padding(8.dp) + ) { + /* Time */ + Row( + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + /* SNR and RSSI */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Snr(meshPacket.rxSnr) + Rssi(meshPacket.rxRssi) + } + } + } + + /* Signal Indicator */ + Box( + modifier = Modifier + .weight(weight = 3f) + .height(IntrinsicSize.Max) + ) { + LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c404d5f6d..45bada70a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,4 +273,18 @@ Request user info New nodes notifications More details + SNR + Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission. + RSSI + Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. + (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. + Device Metrics Logs + Environment Metrics Logs + Signal Metrics Logs + Bad + Fair + Good + None + Signal + Signal Quality