Skip to content

Commit

Permalink
feat: Signal Metrics (#1340)
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert-0410 authored Oct 23, 2024
1 parent 551f5c9 commit bb345e7
Show file tree
Hide file tree
Showing 10 changed files with 662 additions and 210 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,13 +30,23 @@ 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)
.distinctUntilChanged()
.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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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,
)
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -296,6 +297,9 @@ fun NavGraph(
metricsState.environmentDisplayFahrenheit,
)
}
composable("SignalMetrics") {
SignalMetricsScreen(metricsState.signalMetrics)
}
composable("RadioConfig") {
RadioConfigScreen(
node = node,
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,21 +128,29 @@ private fun NodeDetailsItemList(

item {
NavCard(
title = "Device Metrics Logs",
title = stringResource(R.string.device_metrics_logs),
icon = Icons.Default.ChargingStation,
enabled = metricsState.hasDeviceMetrics()
) {
onNavigate("DeviceMetrics")
}

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,
Expand Down
158 changes: 124 additions & 34 deletions app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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()

Expand All @@ -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(),
Expand All @@ -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))
Expand Down Expand Up @@ -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<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
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)
) {
Expand Down
Loading

0 comments on commit bb345e7

Please sign in to comment.