feat: Signal Metrics (#1340)

This commit is contained in:
Robert-0410 2024-10-23 13:31:31 -07:00 committed by GitHub
parent 551f5c9fc5
commit bb345e7437
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 665 additions and 213 deletions

View file

@ -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<List<Telemetry>> =
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<List<MeshPacket>> =
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)
}

View file

@ -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<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = 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,
)
}

View file

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

View file

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

View file

@ -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<Color>,
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
}
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
)
}
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
)
}
}
/**
* 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 LegendLabel(text: String, color: Color, isLine: Boolean = false) {
fun Legend(legendData: List<LegendData>, 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<Pair<Int, Int>>, onDismiss: () -> Unit) {
AlertDialog(
title = {
Text(
text = stringResource(R.string.info),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
text = {
Column {
for (pair in pairedRes) {
Text(
text = stringResource(pair.first),
style = TextStyle(fontWeight = FontWeight.Bold),
textDecoration = TextDecoration.Underline
)
Text(
text = stringResource(pair.second),
style = TextStyle.Default,
)
Spacer(modifier = Modifier.height(24.dp))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.close))
}
},
shape = RoundedCornerShape(16.dp),
backgroundColor = MaterialTheme.colors.background
)
}
@Composable
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
Canvas(
modifier = Modifier.size(4.dp)
) {

View file

@ -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<Telemetry>) {
@ -64,7 +63,13 @@ fun DeviceMetricsScreen(telemetries: List<Telemetry>) {
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 {}
}

View file

@ -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<Telemetry>, environmentDisplayFahrenheit: Boolean) {
@ -65,12 +89,25 @@ fun EnvironmentMetricsScreen(telemetries: List<Telemetry>, 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<Telemetry>, environmentDisplayFah
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List<Telemetry>) {
private fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
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))
}
}

View file

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

View file

@ -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<MeshPacket>) {
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<MeshPacket>,
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)
}
}
}
}
}
}

View file

@ -273,4 +273,18 @@
<string name="request_userinfo">Request user info</string>
<string name="meshtastic_new_nodes_notifications">New nodes notifications</string>
<string name="more_details">More details</string>
<string name="snr">SNR</string>
<string name="snr_definition">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.</string>
<string name="rssi">RSSI</string>
<string name="rssi_definition">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.</string>
<string name="iaq_definition">(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0500.</string>
<string name="device_metrics_logs">Device Metrics Logs</string>
<string name="env_metrics_logs">Environment Metrics Logs</string>
<string name="sig_metrics_logs">Signal Metrics Logs</string>
<string name="bad">Bad</string>
<string name="fair">Fair</string>
<string name="good">Good</string>
<string name="none_quality">None</string>
<string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string>
</resources>