mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Signal Metrics (#1340)
This commit is contained in:
parent
551f5c9fc5
commit
bb345e7437
10 changed files with 665 additions and 213 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 0–500.</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue