Refactor: Replace custom charts with Vico library (#4348)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-28 18:52:59 -06:00 committed by GitHub
parent d651bbeaa2
commit 70a1b3c479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1689 additions and 1338 deletions

View file

@ -18,6 +18,7 @@ package com.geeksville.mesh.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CellTower
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
@ -49,6 +50,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.environment
import org.meshtastic.core.strings.host
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.position_log
import org.meshtastic.core.strings.power
@ -61,6 +63,7 @@ import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
@ -234,6 +237,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
) {
it.destNum
}
NodeDetailRoutes.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
else -> Unit
}
}
@ -312,6 +323,12 @@ enum class NodeDetailRoute(
Icons.Rounded.PermScanWifi,
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
NEIGHBOR_INFO(
Res.string.neighbor_info,
NodeDetailRoutes.NeighborInfoLog::class,
Icons.Rounded.Groups,
{ metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
POWER(
Res.string.power,
NodeDetailRoutes.PowerMetrics::class,

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import org.meshtastic.proto.MeshProtos
val MeshProtos.MeshPacket.neighborInfo: MeshProtos.NeighborInfo?
get() =
if (hasDecoded() && decoded.portnumValue == 71) { // NEIGHBORINFO_APP_VALUE = 71
runCatching { MeshProtos.NeighborInfo.parseFrom(decoded.payload) }.getOrNull()
} else {
null
}
fun MeshProtos.NeighborInfo.getNeighborInfoResponse(
getUser: (nodeNum: Int) -> String,
header: String = "Neighbors:",
): String = buildString {
append(header)
append("\n\n")
if (neighborsList.isEmpty()) {
append("No neighbors reported.")
} else {
neighborsList.forEach { n ->
append("")
append(getUser(n.nodeId))
append(" (SNR: ")
append(n.snr)
append(")\n")
}
}
}
fun MeshProtos.MeshPacket.getNeighborInfoResponse(
getUser: (nodeNum: Int) -> String,
header: String = "Neighbors:",
): String? = neighborInfo?.getNeighborInfoResponse(getUser, header)

View file

@ -82,6 +82,8 @@ object NodeDetailRoutes {
@Serializable data class HostMetricsLog(val destNum: Int) : Route
@Serializable data class PaxMetrics(val destNum: Int) : Route
@Serializable data class NeighborInfoLog(val destNum: Int) : Route
}
object SettingsRoutes {

View file

@ -425,7 +425,6 @@
<string name="position_log">Position</string>
<string name="last_position_update">Last position update</string>
<string name="env_metrics_log">Environment Metrics</string>
<string name="sig_metrics_log">Signal Metrics</string>
<string name="administration">Administration</string>
<string name="remote_admin">Remote Administration</string>
<string name="bad">Bad</string>

View file

@ -53,6 +53,9 @@ dependencies {
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m2)
implementation(libs.vico.compose.m3)
googleImplementation(libs.location.services)
googleImplementation(libs.maps.compose)

View file

@ -51,19 +51,15 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_local_stats
import org.meshtastic.core.strings.request_telemetry
import org.meshtastic.core.strings.telemetry
import org.meshtastic.core.strings.userinfo
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.Chart
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Speed
import org.meshtastic.core.ui.icon.Temperature
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
@ -137,9 +133,10 @@ private fun rememberTelemetricFeatures(
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = Res.string.neighbor_info,
icon = MeshtasticIcons.Groups,
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
icon = LogsType.NEIGHBOR_INFO.icon,
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
logsType = LogsType.NEIGHBOR_INFO,
isVisible = { it.capabilities.canRequestNeighborInfo },
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
@ -147,9 +144,9 @@ private fun rememberTelemetricFeatures(
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = null,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
isVisible = { it.hopsAway == 0 && !isLocal },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
@ -178,11 +175,6 @@ private fun rememberTelemetricFeatures(
content = { PowerMetrics(it) },
hasContent = { it.hasPowerMetrics },
),
TelemetricFeature(
titleRes = Res.string.request_local_stats,
icon = MeshtasticIcons.Speed,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
),
TelemetricFeature(
titleRes = LogsType.HOST.titleRes,
icon = LogsType.HOST.icon,

View file

@ -126,6 +126,19 @@ constructor(
.distinctUntilChanged()
val trResFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE).distinctUntilChanged()
val niReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE)
.map { logs ->
logs.filter { log ->
with(log.fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId
}
}
}
.distinctUntilChanged()
val niResFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP_VALUE).distinctUntilChanged()
combine(
nodeRepository.ourNodeInfo,
@ -139,6 +152,8 @@ constructor(
paxLogsFlow,
trReqsFlow,
trResFlow,
niReqsFlow,
niResFlow,
meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
@ -159,11 +174,13 @@ constructor(
paxLogs = args[8] as List<MeshLog>,
tracerouteRequests = args[9] as List<MeshLog>,
tracerouteResults = args[10] as List<MeshLog>,
firmwareEdition = args[11] as MeshProtos.FirmwareEdition?,
stable = args[12] as FirmwareRelease?,
alpha = args[13] as FirmwareRelease?,
lastTracerouteTime = (args[14] as Map<Int, Long>)[nodeId],
lastRequestNeighborsTime = (args[15] as Map<Int, Long>)[nodeId],
neighborInfoRequests = args[11] as List<MeshLog>,
neighborInfoResults = args[12] as List<MeshLog>,
firmwareEdition = args[13] as MeshProtos.FirmwareEdition?,
stable = args[14] as FirmwareRelease?,
alpha = args[15] as FirmwareRelease?,
lastTracerouteTime = (args[16] as Map<Int, Long>)[nodeId],
lastRequestNeighborsTime = (args[17] as Map<Int, Long>)[nodeId],
)
}
.flatMapLatest { data ->
@ -194,6 +211,8 @@ constructor(
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,
tracerouteResults = data.tracerouteResults,
neighborInfoRequests = data.neighborInfoRequests,
neighborInfoResults = data.neighborInfoResults,
firmwareEdition = data.firmwareEdition,
latestStableFirmware = data.stable ?: FirmwareRelease(),
latestAlphaFirmware = data.alpha ?: FirmwareRelease(),
@ -220,6 +239,7 @@ constructor(
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@ -314,6 +334,8 @@ private data class NodeDetailUiStateData(
val paxLogs: List<MeshLog>,
val tracerouteRequests: List<MeshLog>,
val tracerouteResults: List<MeshLog>,
val neighborInfoRequests: List<MeshLog>,
val neighborInfoResults: List<MeshLog>,
val firmwareEdition: MeshProtos.FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,

View file

@ -38,10 +38,10 @@ import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_device_metrics
import org.meshtastic.core.strings.request_environment_metrics
import org.meshtastic.core.strings.request_host_metrics
import org.meshtastic.core.strings.request_local_stats
import org.meshtastic.core.strings.request_pax_metrics
import org.meshtastic.core.strings.request_power_metrics
import org.meshtastic.core.strings.requesting_from
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.user_info
import javax.inject.Inject
@ -128,7 +128,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.request_local_stats
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}

View file

@ -0,0 +1,94 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.Zoom
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarkerVisibilityListener
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
/**
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
* selection synchronization.
*
* @param modelProducer The [CartesianChartModelProducer] for the chart.
* @param layers The chart layers (e.g., LineCartesianLayer).
* @param modifier The modifier for the chart host.
* @param startAxis The start vertical axis.
* @param endAxis The end vertical axis.
* @param bottomAxis The bottom horizontal axis.
* @param marker The marker to show on interaction.
* @param selectedX The currently selected X value (used for persistent markers).
* @param onPointSelected Callback when a point is selected via interaction.
* @param vicoScrollState The scroll state for the chart.
*/
@Composable
fun GenericMetricChart(
modelProducer: CartesianChartModelProducer,
layers: List<LineCartesianLayer>,
modifier: Modifier = Modifier,
startAxis: VerticalAxis<Axis.Position.Vertical.Start>? = null,
endAxis: VerticalAxis<Axis.Position.Vertical.End>? = null,
bottomAxis: HorizontalAxis<Axis.Position.Horizontal.Bottom>? = null,
marker: CartesianMarker? = null,
selectedX: Double? = null,
onPointSelected: ((Double) -> Unit)? = null,
vicoScrollState: VicoScrollState = rememberVicoScrollState(),
) {
val markerVisibilityListener =
remember(onPointSelected) {
object : CartesianMarkerVisibilityListener {
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
}
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
}
}
}
CartesianChartHost(
chart =
@Suppress("SpreadOperator")
rememberCartesianChart(
*layers.toTypedArray(),
startAxis = startAxis,
endAxis = endAxis,
bottomAxis = bottomAxis,
marker = marker,
markerVisibilityListener = markerVisibilityListener,
persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
),
modelProducer = modelProducer,
modifier = modifier,
scrollState = vicoScrollState,
zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content),
)
}

View file

@ -0,0 +1,269 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget
import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesianMarker
import com.patrykandpatrick.vico.compose.common.Fill
import com.patrykandpatrick.vico.compose.common.Insets
import com.patrykandpatrick.vico.compose.common.MarkerCornerBasedShape
import com.patrykandpatrick.vico.compose.common.component.ShapeComponent
import com.patrykandpatrick.vico.compose.common.component.TextComponent
import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent
import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent
import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
/**
* Utility object for chart styling and component creation. Provides reusable styled lines, points, and axes for Vico
* charts.
*/
object ChartStyling {
// Point sizes
const val SMALL_POINT_SIZE_DP = 6f
const val MEDIUM_POINT_SIZE_DP = 8f
const val LARGE_POINT_SIZE_DP = 10f
// Line stroke widths
const val THIN_LINE_WIDTH_DP = 1.5f
const val MEDIUM_LINE_WIDTH_DP = 2f
const val THICK_LINE_WIDTH_DP = 2.5f
/**
* Creates a solid line with optional point markers.
*
* @param lineColor The color of the line
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
* @param lineWidth Width of the line in dp
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createStyledLine(
lineColor: Color,
pointSize: Float? = MEDIUM_POINT_SIZE_DP,
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
pointProvider =
pointSize?.let {
LineCartesianLayer.PointProvider.single(
LineCartesianLayer.Point(
rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape),
size = it.dp,
),
)
},
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
)
/**
* Creates a transparent line (no line, only points). Useful for distinguishing multiple metrics on the same chart.
*
* @param pointColor The color of the point markers
* @param pointSize Size of point markers in dp
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createPointOnlyLine(pointColor: Color, pointSize: Float = MEDIUM_POINT_SIZE_DP): LineCartesianLayer.Line =
LineCartesianLayer.rememberLine(
// we still need to give the line a color, the Marker derives the label color from the line
fill = LineCartesianLayer.LineFill.single(Fill(pointColor)),
// magic sauce to make the line disappear
stroke = LineCartesianLayer.LineStroke.Dashed(thickness = 0.dp, dashLength = 0.dp),
pointProvider =
LineCartesianLayer.PointProvider.single(
LineCartesianLayer.Point(
rememberShapeComponent(fill = Fill(pointColor), shape = CircleShape),
size = pointSize.dp,
),
),
)
/**
* Creates a line with a gradient fill effect. The gradient goes from the line color to transparent.
*
* @param lineColor The primary color of the line
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
* @param lineWidth Width of the line in dp
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createGradientLine(
lineColor: Color,
pointSize: Float? = MEDIUM_POINT_SIZE_DP,
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
): LineCartesianLayer.Line {
val gradientBrush =
Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.1f)))
return LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)),
pointProvider =
pointSize?.let {
LineCartesianLayer.PointProvider.single(
LineCartesianLayer.Point(
rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape),
size = it.dp,
),
)
},
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
)
}
/**
* Creates a bold line suitable for highlighting primary metrics.
*
* @param lineColor The color of the line
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createBoldLine(lineColor: Color, pointSize: Float? = LARGE_POINT_SIZE_DP): LineCartesianLayer.Line =
createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THICK_LINE_WIDTH_DP)
/**
* Creates a subtle line suitable for secondary metrics.
*
* @param lineColor The color of the line
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
fun createSubtleLine(lineColor: Color, pointSize: Float? = SMALL_POINT_SIZE_DP): LineCartesianLayer.Line =
createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THIN_LINE_WIDTH_DP)
/**
* Gets Material 3 theme-aware colors with opacity. Useful for creating color variants while respecting the current
* theme.
*
* @param baseColor The base color
* @param alpha The alpha/opacity value (0f-1f)
* @return Color with adjusted alpha
*/
fun createThemedColor(baseColor: Color, alpha: Float = 1f): Color = baseColor.copy(alpha = alpha)
/**
* Creates and remembers a default [CartesianMarker] styled for the Meshtastic theme.
*
* @param valueFormatter The formatter for the marker label content.
* @param showIndicator Whether to show the point indicator on the line/column.
* @return A configured [CartesianMarker]
*/
@Composable
fun rememberMarker(
valueFormatter: DefaultCartesianMarker.ValueFormatter =
DefaultCartesianMarker.ValueFormatter.default(colorCode = true),
showIndicator: Boolean = true,
): CartesianMarker {
val labelBackground =
rememberShapeComponent(
fill = Fill(MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MarkerCornerBasedShape(MaterialTheme.shapes.extraSmall),
strokeFill = Fill(MaterialTheme.colorScheme.outlineVariant),
strokeThickness = 1.dp,
)
val label =
rememberTextComponent(
style =
TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
),
background = labelBackground,
padding = Insets(horizontal = 8.dp, vertical = 4.dp),
margins = Insets(bottom = 4.dp),
)
val guideline =
rememberLineComponent(
fill = Fill(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)),
thickness = 1.dp,
)
val indicator =
if (showIndicator) {
// Force alpha to 1f so the indicator is visible even for "invisible" lines
{ color: Color -> ShapeComponent(fill = Fill(color.copy(alpha = 1f)), shape = CircleShape) }
} else {
null
}
return rememberDefaultCartesianMarker(
label = label,
valueFormatter = valueFormatter,
guideline = guideline,
indicator = indicator,
)
}
/**
* Creates a [DefaultCartesianMarker.ValueFormatter] that colors the text to match the series color.
*
* @param format A lambda that provides the string content for a given Y value and its series color.
*/
fun createColoredMarkerValueFormatter(
format: (value: Double, color: Color) -> String,
): DefaultCartesianMarker.ValueFormatter = DefaultCartesianMarker.ValueFormatter { _, targets ->
buildAnnotatedString {
targets.forEachIndexed { index, target ->
if (index > 0) append(", ")
if (target is LineCartesianLayerMarkerTarget) {
target.points.forEachIndexed { pointIndex, point ->
if (pointIndex > 0) append(", ")
// Force alpha to 1f so text is readable even if the line is transparent/subtle
val color = point.color.copy(alpha = .8f)
val text = format(point.entry.y, color)
withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) }
}
}
}
}
}
/**
* Creates a standard [HorizontalAxis.ItemPlacer] with optimized spacing.
*
* @param spacing The number of data points to skip between labels.
*/
fun rememberItemPlacer(spacing: Int = 50): HorizontalAxis.ItemPlacer =
HorizontalAxis.ItemPlacer.aligned(spacing = { spacing }, addExtremeLabelPadding = true)
/**
* Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis
* labels.
*/
@Composable
fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent =
rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium))
}

View file

@ -14,25 +14,34 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.node.metrics
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -40,48 +49,96 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.vector.ImageVector
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 androidx.compose.ui.unit.sp
import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.snr
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import java.text.DateFormat
import java.util.Date
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
}
const val SCROLL_BIAS = 0.5f
private const val LINE_ON = 10f
private const val LINE_OFF = 20f
private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
private const val DATE_Y = 32f
private const val LINE_LIMIT = 4
private const val TEXT_PAINT_ALPHA = 192
/**
* Gets the Material 3 primary color with optional opacity adjustment.
*
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
* @return Color based on current theme's primary color.
*/
@Composable
fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
/**
* Gets the Material 3 secondary color with optional opacity adjustment.
*
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
* @return Color based on current theme's secondary color.
*/
@Composable
fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
/**
* Gets the Material 3 tertiary color with optional opacity adjustment.
*
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
* @return Color based on current theme's tertiary color.
*/
@Composable
fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
/**
* Gets the Material 3 error color with optional opacity adjustment.
*
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
* @return Color based on current theme's error color.
*/
@Composable
fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
/** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */
val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ ->
val date = Date((value * MS_PER_SEC.toDouble()).toLong())
val xLength = context.ranges.xLength
val zoom = if (context is CartesianDrawingContext) context.zoom else 1f
val visibleSpan = xLength / zoom
val formatter =
when {
visibleSpan <= 3600 -> TIME_SECONDS_FORMAT // < 1 hour visible
visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT // < 2 days visible
visibleSpan <= 86400 * 14 -> DATE_TIME_MINUTE_FORMAT // < 2 weeks visible
else -> DATE_FORMAT
}
formatter.format(date)
}
}
data class LegendData(
val nameRes: StringResource,
@ -106,137 +163,6 @@ fun ChartHeader(amount: Int) {
}
}
/**
* Draws chart lines with respect to the Y-axis.
*
* @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart.
*/
@Composable
fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List<Color>) {
/* 100 is a good number to divide into quarters */
val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT
Canvas(modifier = modifier) {
val lineStart = 0f
val height = size.height
val width = size.width
/* Horizontal Lines */
var lineY = 0f
for (i in 0..LINE_LIMIT) {
val ratio = lineY / MAX_PERCENT_VALUE
val y = height - (ratio * height)
drawLine(
start = Offset(lineStart, y),
end = Offset(width, y),
color = lineColors[i],
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
lineY += verticalSpacing
}
}
}
/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */
@Composable
fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) {
val range = maxValue - minValue
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 = minValue
repeat(LINE_LIMIT + 1) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint)
label += verticalSpacing
}
}
}
}
/** Draws the vertical lines to help the user relate the plotted data within a time frame. */
@Composable
fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) {
val range = newest - oldest
val density = LocalDensity.current
val lineColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
/* Cut out the time remaining in order to place the lines on the dot. */
val timeRemaining = oldest % timeInterval
var current = oldest.toLong()
current -= timeRemaining
current += timeInterval
val textPaint =
Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
/* Vertical Lines with labels */
drawContext.canvas.nativeCanvas.apply {
while (current <= newest) {
val ratio = (current - oldest).toFloat() / range
val x = (ratio * width)
drawLine(
start = Offset(x, 0f),
end = Offset(x, height),
color = lineColor,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
/* Time */
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
/* Date */
drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint)
current += timeInterval
}
}
}
}
/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */
@Composable
fun TimeLabels(oldest: Int, newest: Int) {
Row {
Text(
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp,
)
}
}
/**
* Creates the legend that identifies the colors used for the graph.
*
@ -326,6 +252,57 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
)
}
@Composable
fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
},
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier =
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Preview
@Composable
private fun LegendPreview() {

View file

@ -14,12 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -28,12 +29,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.rememberScrollState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -44,26 +46,28 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
@ -73,10 +77,9 @@ import org.meshtastic.core.strings.battery
import org.meshtastic.core.strings.ch_util_definition
import org.meshtastic.core.strings.channel_air_util
import org.meshtastic.core.strings.channel_utilization
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
@ -85,18 +88,10 @@ import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private enum class Device(val color: Color) {
BATTERY(Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
@ -134,8 +129,12 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.deviceMetricsFiltered(selectedTimeFrame)
val data = state.deviceMetrics
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
@ -152,6 +151,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.device_metrics_log),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -183,20 +183,33 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = data.indexOfFirst { it.time.toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, telemetry ->
DeviceMetricsCard(
telemetry = telemetry,
isSelected = telemetry.time.toDouble() == selectedX,
onClick = {
selectedX = telemetry.time.toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f))
}
},
)
}
}
}
}
}
@ -206,121 +219,82 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
val graphColor = MaterialTheme.colorScheme.onSurface
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) return
val (oldest, newest) =
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
val timeDiff = newest.time - oldest.time
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
Row {
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
) {
/*
* The order of the colors are with respect to the ChUtil.
* 25 - 49 Orange
* 50 - 100 Red
*/
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
)
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
/* Plot Battery Line, ChUtil, and AirUtilTx */
Canvas(modifier = modifier.width(dp)) {
val height = size.height
val width = size.width
for (i in telemetries.indices) {
val telemetry = telemetries[i]
/* x-value time */
val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff
val x = xRatio * width
/* Channel Utilization */
plotPoint(
drawContext = drawContext,
color = Device.CH_UTIL.color,
x = x,
value = telemetry.deviceMetrics.channelUtilization,
divisor = MAX_PERCENT_VALUE,
)
/* Air Utilization Transmit */
plotPoint(
drawContext = drawContext,
color = Device.AIR_UTIL.color,
x = x,
value = telemetry.deviceMetrics.airUtilTx,
divisor = MAX_PERCENT_VALUE,
)
val modelProducer = remember { CartesianChartModelProducer() }
val batteryColor = Device.BATTERY.color
val chUtilColor = Device.CH_UTIL.color
val airUtilColor = Device.AIR_UTIL.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
batteryColor -> "Battery: %.1f%%".format(value)
chUtilColor -> "ChUtil: %.1f%%".format(value)
airUtilColor -> "AirUtil: %.1f%%".format(value)
else -> "%.1f%%".format(value)
}
},
)
/* Battery Line */
var index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = Device.BATTERY.color,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
}
LaunchedEffect(telemetries) {
modelProducer.runTransaction {
lineSeries {
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.batteryLevel })
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.channelUtilization })
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.airUtilTx })
}
}
YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f)
}
Spacer(modifier = Modifier.height(16.dp))
val axisLabel = ChartStyling.rememberAxisLabel()
GenericMetricChart(
modelProducer = modelProducer,
modifier = modifier.padding(8.dp),
layers =
listOf(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createBoldLine(
lineColor = batteryColor,
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
),
ChartStyling.createPointOnlyLine(
pointColor = chUtilColor,
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
),
ChartStyling.createPointOnlyLine(
pointColor = airUtilColor,
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
),
),
),
),
startAxis =
VerticalAxis.rememberStart(label = axisLabel, valueFormatter = { _, value, _ -> "%.0f%%".format(value) }),
bottomAxis =
HorizontalAxis.rememberBottom(
label = axisLabel,
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:MagicNumber") // fake data
@ -346,32 +320,46 @@ private fun DeviceMetricsChartPreview() {
DeviceMetricsChart(
modifier = Modifier.height(400.dp),
telemetries = telemetries,
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
promptInfoDialog = {},
vicoScrollState = rememberVicoScrollState(),
selectedX = null,
onPointSelected = {},
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DeviceMetricsCard(telemetry: Telemetry) {
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.deviceMetrics
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = MaterialTheme.typography.titleMediumEmphasized,
)
MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -407,7 +395,7 @@ private fun DeviceMetricsCardPreview() {
.setUptimeSeconds(7200),
)
.build()
AppTheme { DeviceMetricsCard(telemetry = telemetry) }
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
@Suppress("detekt:MagicNumber") // fake data
@ -448,22 +436,18 @@ private fun DeviceMetricsScreenPreview() {
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries.reversed(),
TimeFrame.TWENTY_FOUR_HOURS,
telemetries = telemetries.reversed(),
promptInfoDialog = { displayInfoDialog = true },
vicoScrollState = rememberVicoScrollState(),
selectedX = null,
onPointSelected = {},
)
SlidingSelector(
TimeFrame.entries.toList(),
TimeFrame.TWENTY_FOUR_HOURS,
onOptionSelected = { /* Preview only */ },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
itemsIndexed(telemetries) { _, telemetry ->
DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {})
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,32 +14,26 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.baro_pressure
import org.meshtastic.core.strings.humidity
@ -49,14 +43,8 @@ import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
import org.meshtastic.feature.node.metrics.GraphUtil.drawPathWithGradient
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos.Telemetry
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
@Suppress("MagicNumber")
private val LEGEND_DATA_1 =
listOf(
@ -117,71 +105,126 @@ private val LEGEND_DATA_3 =
),
)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
}
val (oldest, newest) = graphData.times
val timeDiff = newest - oldest
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
val modelProducer = remember { CartesianChartModelProducer() }
val shouldPlot = graphData.shouldPlot
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
val visibleWidthPx = screenWidth * chartWidthRatio
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
val allLegendData = LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
LaunchedEffect(telemetries, graphData) {
modelProducer.runTransaction {
/* Pressure on its own layer/axis */
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
lineSeries {
series(
x = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t)?.let { t.time } },
y = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t) },
)
}
}
/* Everything else on the default axis */
Environment.entries.forEach { metric ->
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
lineSeries {
series(
x = telemetries.mapNotNull { t -> metric.getValue(t)?.let { t.time } },
y = telemetries.mapNotNull { t -> metric.getValue(t) },
)
}
}
}
}
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
"%s: %.1f".format(label, value)
},
)
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
BarometricPressureYAxisLabel(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
minValue = graphData.leftMinMax.first,
maxValue = graphData.leftMinMax.second,
val layers = mutableListOf<LineCartesianLayer>()
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
layers.add(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(
Environment.BAROMETRIC_PRESSURE.color,
ChartStyling.MEDIUM_POINT_SIZE_DP,
),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
)
ChartContent(
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
scrollState = scrollState,
dp = dp,
oldest = oldest,
newest = newest,
selectedTime = selectedTime,
telemetries = telemetries,
graphData = graphData,
rightMin = graphData.rightMinMax.first,
rightMax = graphData.rightMinMax.second,
timeDiff = timeDiff,
)
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
MaterialTheme.colorScheme.onSurface,
minValue = graphData.rightMinMax.first,
maxValue = graphData.rightMinMax.second,
}
Environment.entries.forEach { metric ->
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
layers.add(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
),
)
}
}
if (layers.isNotEmpty()) {
val otherMetricsPlotted =
Environment.entries.filter { it != Environment.BAROMETRIC_PRESSURE && shouldPlot[it.ordinal] }
val endAxisColor = if (otherMetricsPlotted.size == 1) otherMetricsPlotted.first().color else onSurfaceColor
GenericMetricChart(
modelProducer = modelProducer,
modifier = modifier.padding(8.dp),
layers = layers,
startAxis =
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color),
valueFormatter = { _, value, _ -> "%.0f hPa".format(value) },
)
} else {
null
},
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = endAxisColor),
valueFormatter = { _, value, _ -> "%.0f".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
}
@ -192,151 +235,6 @@ fun EnvironmentMetricsChart(
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:LongMethod")
@Composable
private fun MetricPlottingCanvas(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
oldest: Int,
timeDiff: Int,
rightMin: Float,
rightMax: Float,
) {
val (pressureMin, pressureMax) = graphData.leftMinMax
val shouldPlot = graphData.shouldPlot
val graphColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
var min: Float
var diff: Float
var index: Int
var first: Int
for (metric in Environment.entries) {
if (!shouldPlot[metric.ordinal]) {
continue
}
if (metric == Environment.BAROMETRIC_PRESSURE) {
diff = pressureMax - pressureMin
min = pressureMin
} else { // Reset for other metrics to use rightMin/rightMax
min = rightMin
diff = rightMax - rightMin
}
index = 0
while (index < telemetries.size) {
first = index
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val rawValue = metric.getValue(telemetry) // This is Float?
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
// lux.
val pointValue =
if (rawValue != null && !rawValue.isNaN()) {
rawValue
} else {
0f
}
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
// scale group.
val currentMin = min
// Avoid division by zero if all values in the current y-axis range are the same.
val currentDiff = if (diff == 0f) 1f else diff
val ratio = (pointValue - currentMin) / currentDiff
var y = height - (ratio * height)
// Final check to ensure y is a valid, plottable coordinate.
if (y.isNaN() || y.isInfinite()) {
y = height // Default to the bottom of the chart if calculation still results in an
// invalid number.
} else {
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
}
return@createPath y
}
drawPathWithGradient(
path = path,
color = metric.color,
height = height,
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
)
}
}
}
}
@Composable
private fun BarometricPressureYAxisLabel(
modifier: Modifier,
shouldPlotBarometricPressure: Boolean,
minValue: Float,
maxValue: Float,
) {
if (shouldPlotBarometricPressure) {
YAxisLabels(
modifier = modifier,
Environment.BAROMETRIC_PRESSURE.color,
minValue = minValue,
maxValue = maxValue,
)
}
}
@Composable
private fun ChartContent(
modifier: Modifier = Modifier,
scrollState: ScrollState,
dp: Dp,
oldest: Int,
newest: Int,
selectedTime: TimeFrame,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
rightMin: Float,
rightMax: Float,
timeDiff: Int,
) {
val graphColor = MaterialTheme.colorScheme.onSurface
Box(
contentAlignment = Alignment.TopStart,
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
MetricPlottingCanvas(
modifier = modifier.width(dp),
telemetries = telemetries,
graphData = graphData,
selectedTime = selectedTime,
oldest = oldest,
timeDiff = timeDiff,
rightMin = rightMin,
rightMax = rightMax,
)
}
}
@Composable
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
@ -346,11 +244,3 @@ private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog:
promptInfoDialog = promptInfoDialog,
)
}
// private const val LINE_ON = 10f
// private const val LINE_OFF = 20f
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
// private const val DATE_Y = 32f
// private const val LINE_LIMIT = 4
// private const val TEXT_PAINT_ALPHA = 192

View file

@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -23,12 +25,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -38,25 +44,28 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
@ -71,27 +80,30 @@ import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.copy
@Suppress("LongMethod")
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
val graphData = environmentState.environmentMetricsForGraphing(state.isFahrenheit)
val data = graphData.metrics
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
@ -126,6 +138,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.env_metrics_log),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -157,20 +170,32 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = processedTelemetries.reversed(),
graphData = graphData,
selectedTime = selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(processedTelemetries) { _, telemetry ->
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isSelected = telemetry.time.toDouble() == selectedX,
onClick = {
selectedX = telemetry.time.toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), SCROLL_BIAS))
}
},
)
}
}
}
}
@ -356,30 +381,49 @@ private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) }
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Text(text = DATE_TIME_FORMAT.format(time), style = MaterialTheme.typography.titleMediumEmphasized)
TemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
}
Spacer(modifier = Modifier.height(8.dp))
HumidityAndBarometricPressureDisplay(envMetrics)
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.ui.graphics.Color
@ -28,7 +27,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.core.ui.theme.GraphColors.Yellow
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos
@Suppress("MagicNumber")
@ -44,7 +42,7 @@ enum class Environment(val color: Color) {
},
SOIL_MOISTURE(Purple) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
telemetry.environmentMetrics.soilMoisture?.toFloat()
telemetry.environmentMetrics.soilMoisture.toFloat()
},
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
@ -53,7 +51,7 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
},
IAQ(Magenta) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat()
},
LUX(LightGreen) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
@ -66,7 +64,7 @@ enum class Environment(val color: Color) {
}
/**
* @param metrics the filtered [List]
* @param metrics the [List] of [TelemetryProtos.Telemetry]
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
* @param leftMinMax [Pair] with the min and max of the barometric pressure
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
@ -84,15 +82,13 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
/**
* Filters [environmentMetrics] based on a [org.meshtastic.feature.node.model.TimeFrame].
* Prepares [environmentMetrics] for graphing.
*
* @param timeFrame used to filter
* @return [EnvironmentGraphingData]
*/
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
fun environmentMetricsFiltered(timeFrame: TimeFrame, useFahrenheit: Boolean = false): EnvironmentGraphingData {
val oldestTime = timeFrame.calculateOldestTime()
val telemetries = environmentMetrics.filter { it.time >= oldestTime }
fun environmentMetricsForGraphing(useFahrenheit: Boolean = false): EnvironmentGraphingData {
val telemetries = environmentMetrics
val shouldPlot = BooleanArray(Environment.entries.size) { false }
if (telemetries.isEmpty()) {
return EnvironmentGraphingData(metrics = telemetries, shouldPlot = shouldPlot.toList())
@ -103,7 +99,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
val maxValues = mutableListOf<Float>()
// Temperature
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature.takeIf { !it.isNaN() } }
if (temperatures.isNotEmpty()) {
var minTempValue = temperatures.minOf { it }
var maxTempValue = temperatures.maxOf { it }
@ -118,7 +114,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
// Relative Humidity
val humidities =
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity.takeIf { !it.isNaN() && it != 0.0f } }
if (humidities.isNotEmpty()) {
minValues.add(humidities.minOf { it })
maxValues.add(humidities.maxOf { it })
@ -126,7 +122,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Soil Temperature
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature.takeIf { !it.isNaN() } }
if (soilTemperatures.isNotEmpty()) {
var minSoilTemperatureValue = soilTemperatures.minOf { it }
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
@ -140,8 +136,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Soil Moisture
val soilMoistures =
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
val soilMoistures = telemetries.mapNotNull { it.environmentMetrics.soilMoisture.takeIf { it != Int.MIN_VALUE } }
if (soilMoistures.isNotEmpty()) {
minValues.add(soilMoistures.minOf { it.toFloat() })
maxValues.add(soilMoistures.maxOf { it.toFloat() })
@ -149,7 +144,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// IAQ
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq.takeIf { it != Int.MIN_VALUE } }
if (iaqs.isNotEmpty()) {
minValues.add(iaqs.minOf { it.toFloat() })
maxValues.add(iaqs.maxOf { it.toFloat() })
@ -157,7 +152,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Barometric Pressure
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure.takeIf { !it.isNaN() } }
var minPressureValue = 0f
var maxPressureValue = 0f
if (pressures.isNotEmpty()) {
@ -167,7 +162,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Lux
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux.takeIf { !it.isNaN() } }
if (luxValues.isNotEmpty()) {
minValues.add(luxValues.minOf { it })
maxValues.add(luxValues.maxOf { it })
@ -175,7 +170,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// UVLux
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux.takeIf { !it.isNaN() } }
if (uvLuxValues.isNotEmpty()) {
minValues.add(uvLuxValues.minOf { it })
maxValues.add(uvLuxValues.maxOf { it })

View file

@ -1,122 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import android.content.res.Resources
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.drawscope.DrawContext
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import org.meshtastic.proto.TelemetryProtos.Telemetry
object GraphUtil {
val RADIUS = Resources.getSystem().displayMetrics.density * 2
/**
* @param value Must be zero-scaled before passing.
* @param divisor The range for the data set.
*/
fun plotPoint(drawContext: DrawContext, color: Color, x: Float, value: Float, divisor: Float) {
val height = drawContext.size.height
val ratio = value / divisor
val y = height - (ratio * height)
drawContext.canvas.drawCircle(
center = Offset(x, y),
radius = RADIUS,
paint = androidx.compose.ui.graphics.Paint().apply { this.color = color },
)
}
/**
* Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` or the last point
* before a time separation between [Telemetry]s.
*
* @param telemetries data used to create the [Path]
* @param index current place in the [List]
* @param path [Path] that will be used to draw
* @param timeRange The time range for the data set
* @param width of the [DrawContext]
* @param timeThreshold to determine significant breaks in time between [Telemetry]s
* @param calculateY (`index`) -> `y` coordinate
* @return the current index after iterating
*/
fun createPath(
telemetries: List<Telemetry>,
index: Int,
path: Path,
oldestTime: Int,
timeRange: Int,
width: Float,
timeThreshold: Long,
calculateY: (Int) -> Float,
): Int {
var i = index
var isNewLine = true
with(path) {
while (i < telemetries.size) {
val telemetry = telemetries[i]
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
/* Check to see if we have a significant time break between telemetries. */
if (nextTelemetry.time - telemetry.time > timeThreshold) {
i++
break
}
val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange
val x1 = x1Ratio * width
val y1 = calculateY(i)
val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange
val x2 = x2Ratio * width
val y2 = calculateY(i + 1)
if (isNewLine || i == 0) {
isNewLine = false
moveTo(x1, y1)
}
quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f)
i++
}
}
return i
}
fun DrawScope.drawPathWithGradient(path: Path, color: Color, height: Float, x1: Float, x2: Float) {
drawPath(path = path, color = color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round))
val fillPath =
android.graphics.Path(path.asAndroidPath()).asComposePath().apply {
lineTo(x1, height)
lineTo(x2, height)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(colors = listOf(color.copy(alpha = 0.5f), Color.Transparent), endY = height),
)
}
}

View file

@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@ -49,7 +50,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
@ -123,6 +123,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "MagicNumber")
@Composable
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) {
@ -141,8 +142,7 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Te
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = MaterialTheme.typography.titleMediumEmphasized,
)
LogLine(
label = stringResource(Res.string.uptime),

View file

@ -62,7 +62,6 @@ import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
@ -107,6 +106,9 @@ constructor(
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
private fun MeshLog.hasValidNeighborInfo(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
/**
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
* freezing when viewing unknown nodes.
@ -198,9 +200,6 @@ constructor(
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _timeFrame
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
@ -229,6 +228,12 @@ constructor(
}
}
fun requestNeighborInfo() {
destNum?.let {
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.longName ?: "")
}
}
init {
initializeFlows()
}
@ -334,6 +339,21 @@ constructor(
.collect {}
}
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
neighborInfoRequests = request.filter { it.hasValidNeighborInfo() },
neighborInfoResults = response,
)
}
}
.collect {}
}
launch {
meshLogRepository.getMeshPacketsFrom(
currentDestNum,
@ -395,10 +415,6 @@ constructor(
Logger.d { "MetricsViewModel cleared" }
}
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Write the persisted Position data out to a CSV file in the specified location. */
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs

View file

@ -0,0 +1,236 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
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.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NeighborInfoLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
showDialog?.let { message ->
SimpleAlertDialog(
title = Res.string.neighbor_info,
text = { SelectionContainer { Text(text = message) } },
onConfirm = { showDialog = null },
onDismiss = { showDialog = null },
)
}
Scaffold(
topBar = {
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.neighbor_info),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
if (!state.isLocal) {
CooldownIconButton(
onClick = { viewModel.requestNeighborInfo() },
cooldownTimestamp = lastRequestNeighborsTime,
) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.neighborInfoRequests, key = { it.uuid }) { log ->
val result =
remember(state.neighborInfoResults, log.fromRadio.packet.id) {
state.neighborInfoResults.find {
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
}
}
val time =
DateUtils.formatDateTime(
context,
log.received_date,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
var expanded by remember { mutableStateOf(false) }
Box {
MetricLogItem(
icon = icon,
text = "$time - $text",
contentDescription = stringResource(Res.string.neighbor_info),
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
result
?.fromRadio
?.packet
?.getNeighborInfoResponse(
::getUsername,
header = getString(Res.string.neighbor_info),
)
?.let {
showDialog =
annotateNeighborInfo(
it,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
}
},
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DeleteItem {
viewModel.deleteLog(log.uuid)
expanded = false
}
}
}
}
}
}
}
/**
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
*/
fun annotateNeighborInfo(
inString: String?,
statusGreen: Color,
statusYellow: Color,
statusOrange: Color,
): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "• NodeName (SNR: 5.5)"
if (line.contains("(SNR: ")) {
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
else -> statusOrange
}
val snrPrefix = "(SNR: "
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
append(")")
} else {
append(line)
}
} else {
append(line)
}
}
}
}

View file

@ -16,23 +16,21 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -45,19 +43,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.MeshLog
@ -72,22 +76,15 @@ import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
import java.text.DateFormat
import java.util.Date
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
PAX(Color.Black, Res.string.pax),
BLE(Color.Cyan, Res.string.ble_devices),
@ -101,73 +98,77 @@ private fun PaxMetricsChart(
totalSeries: List<Pair<Int, Int>>,
bleSeries: List<Pair<Int, Int>>,
wifiSeries: List<Pair<Int, Int>>,
minValue: Float,
maxValue: Float,
timeFrame: TimeFrame,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
if (totalSeries.isEmpty()) return
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val times = totalSeries.map { it.first }
val minTime = times.minOrNull() ?: 0
val maxTime = times.maxOrNull() ?: 1
val timeDiff = maxTime - minTime
val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) }
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
val visibleOldest = minTime + (timeDiff * leftRatio).toInt()
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray })
TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval())
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
val width = size.width
val height = size.height
fun xForTime(t: Int): Float =
if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width
fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height
fun drawLine(series: List<Pair<Int, Int>>, color: Color) {
for (i in 1 until series.size) {
drawLine(
color = color,
start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)),
end = Offset(xForTime(series[i].first), yForValue(series[i].second)),
strokeWidth = 2.dp.toPx(),
)
}
}
drawLine(bleSeries, PaxSeries.BLE.color)
drawLine(wifiSeries, PaxSeries.WIFI.color)
drawLine(totalSeries, PaxSeries.PAX.color)
val modelProducer = remember { CartesianChartModelProducer() }
val paxColor = PaxSeries.PAX.color
val bleColor = PaxSeries.BLE.color
val wifiColor = PaxSeries.WIFI.color
LaunchedEffect(totalSeries, bleSeries, wifiSeries) {
modelProducer.runTransaction {
lineSeries {
series(x = bleSeries.map { it.first }, y = bleSeries.map { it.second })
series(x = wifiSeries.map { it.first }, y = wifiSeries.map { it.second })
series(x = totalSeries.map { it.first }, y = totalSeries.map { it.second })
}
}
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue,
)
}
Spacer(modifier = Modifier.height(16.dp))
val axisLabel = ChartStyling.rememberAxisLabel()
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(1f)) {
bleColor -> "BLE: %.0f".format(value)
wifiColor -> "WiFi: %.0f".format(value)
paxColor -> "PAX: %.0f".format(value)
else -> "%.0f".format(value)
}
},
)
GenericMetricChart(
modelProducer = modelProducer,
modifier = modifier.padding(8.dp),
layers =
listOf(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(
lineColor = bleColor,
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
),
ChartStyling.createGradientLine(
lineColor = wifiColor,
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
),
ChartStyling.createBoldLine(
lineColor = paxColor,
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
),
),
),
),
startAxis = VerticalAxis.rememberStart(label = axisLabel),
bottomAxis =
HorizontalAxis.rememberBottom(
label = axisLabel,
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
}
@Composable
@ -176,6 +177,11 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
@ -188,7 +194,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
}
val dateFormat = DateFormat.getDateTimeInstance()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
// Only show logs that can be decoded as PaxcountProtos.Paxcount
val paxMetrics =
state.paxMetrics.mapNotNull { log ->
@ -200,10 +205,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
}
}
// Prepare data for graph
val oldestTime = timeFrame.calculateOldestTime()
val graphData =
paxMetrics
.filter { it.first.received_date / 1000 >= oldestTime }
.map {
val t = (it.first.received_date / 1000).toInt()
Triple(t, it.second.ble, it.second.wifi)
@ -212,8 +215,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
val totalSeries = graphData.map { it.first to (it.second + it.third) }
val bleSeries = graphData.map { it.first to it.second }
val wifiSeries = graphData.map { it.first to it.third }
val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f)
val minValue = 0f
val legendData =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
@ -225,6 +226,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.pax_metrics_log),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -242,14 +244,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Time frame selector
SlidingSelector(
options = TimeFrame.entries.toList(),
selectedOption = timeFrame,
onOptionSelected = { timeFrame = it },
) { tf: TimeFrame ->
OptionLabel(stringResource(tf.strRes))
}
// Graph
if (graphData.isNotEmpty()) {
ChartHeader(graphData.size)
@ -258,9 +252,15 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
minValue = minValue,
maxValue = maxValue,
timeFrame = timeFrame,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = paxMetrics.indexOfFirst { (it.first.received_date / 1000).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
}
// List
@ -271,8 +271,27 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
textAlign = TextAlign.Center,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
state = lazyListState,
) {
itemsIndexed(paxMetrics) { _, (log, pax) ->
PaxMetricsItem(
log = log,
pax = pax,
dateFormat = dateFormat,
isSelected = (log.received_date / 1000).toDouble() == selectedX,
onClick = {
selectedX = (log.received_date / 1000).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x((log.received_date / 1000).toDouble(), 0.5f),
)
}
},
)
}
}
}
}
@ -349,29 +368,53 @@ fun PaxcountInfo(
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateFormat.format(Date(log.received_date)),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
val total = pax.ble + pax.wifi
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
fun PaxMetricsItem(
log: MeshLog,
pax: PaxcountProtos.Paxcount,
dateFormat: DateFormat,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = summary,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f, fill = true),
)
Text(
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
style = MaterialTheme.typography.bodyMedium,
text = dateFormat.format(Date(log.received_date)),
style = MaterialTheme.typography.titleMediumEmphasized,
textAlign = TextAlign.End,
modifier = Modifier.alignByBaseline(),
modifier = Modifier.fillMaxWidth(),
)
val total = pax.ble + pax.wifi
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = summary,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f, fill = true),
)
Text(
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
modifier = Modifier.alignByBaseline(),
)
}
}
}
}

View file

@ -14,12 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -28,14 +29,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -45,25 +47,30 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
@ -72,28 +79,19 @@ import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos.Telemetry
import kotlin.math.ceil
import kotlin.math.floor
@Suppress("MagicNumber")
private enum class Power(val color: Color, val min: Float, val max: Float) {
CURRENT(InfantryBlue, -500f, 500f),
;
/** Difference between the metrics `max` and `min` values. */
fun difference() = max - min
private enum class PowerMetric(val color: Color) {
CURRENT(InfantryBlue),
VOLTAGE(Red),
}
private enum class PowerChannel(val strRes: StringResource) {
@ -102,31 +100,20 @@ private enum class PowerChannel(val strRes: StringResource) {
THREE(Res.string.channel_3),
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private const val VOLTAGE_STICK_TO_ZERO_RANGE = 2f
private val VOLTAGE_COLOR = Red
fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
val valueMin = floor(valueMin)
val min =
if (valueMin == 0f || (valueMin >= 0f && valueMin - VOLTAGE_STICK_TO_ZERO_RANGE <= 0f)) {
0f
} else {
valueMin - VOLTAGE_STICK_TO_ZERO_RANGE
}
val max = ceil(valueMax)
return Pair(min, max)
}
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
LegendData(
nameRes = Res.string.current,
color = PowerMetric.CURRENT.color,
isLine = true,
environmentMetric = null,
),
LegendData(
nameRes = Res.string.voltage,
color = PowerMetric.VOLTAGE.color,
isLine = true,
environmentMetric = null,
),
)
@Suppress("LongMethod")
@ -134,10 +121,14 @@ private val LEGEND_DATA =
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.powerMetricsFiltered(selectedTimeFrame)
val data = state.powerMetrics
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
@ -153,6 +144,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.power_metrics_log),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -176,27 +168,32 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
PowerMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
selectedChannel,
selectedChannel = selectedChannel,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = data.indexOfFirst { it.time.toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
SlidingSelector(
PowerChannel.entries.toList(),
selectedChannel,
onOptionSelected = { selectedChannel = it },
) {
OptionLabel(stringResource(it.strRes))
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, telemetry ->
PowerMetricsCard(
telemetry = telemetry,
isSelected = telemetry.time.toDouble() == selectedX,
onClick = {
selectedX = telemetry.time.toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f))
}
},
)
}
}
Spacer(modifier = Modifier.height(2.dp))
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
}
}
}
@ -206,155 +203,119 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
private fun PowerMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
selectedTime: TimeFrame,
selectedChannel: PowerChannel,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
}
val (oldest, newest) =
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
val timeDiff = newest.time - oldest.time
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by
remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val currentDiff = Power.CURRENT.difference()
val (voltageMin, voltageMax) =
minMaxGraphVoltage(
retrieveVoltage(selectedChannel, telemetries.minBy { retrieveVoltage(selectedChannel, it) }),
retrieveVoltage(selectedChannel, telemetries.maxBy { retrieveVoltage(selectedChannel, it) }),
)
val voltageDiff = voltageMax - voltageMin
Row {
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Power.CURRENT.color,
minValue = Power.CURRENT.min,
maxValue = Power.CURRENT.max,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
/* Plot */
Canvas(modifier = modifier.width(dp)) {
val width = size.width
val height = size.height
/* Voltage */
var index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = (retrieveVoltage(selectedChannel, telemetry) - voltageMin) / voltageDiff
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = VOLTAGE_COLOR,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
}
/* Current */
index = 0
while (index < telemetries.size) {
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest.time,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff
val y = height - (ratio * height)
return@createPath y
}
drawPath(
path = path,
color = Power.CURRENT.color,
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
)
val modelProducer = remember { CartesianChartModelProducer() }
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(1f)) {
currentColor -> "Current: %.0f mA".format(value)
voltageColor -> "Voltage: %.1f V".format(value)
else -> "%.1f".format(value)
}
},
)
LaunchedEffect(telemetries, selectedChannel) {
modelProducer.runTransaction {
lineSeries {
series(x = telemetries.map { it.time }, y = telemetries.map { retrieveCurrent(selectedChannel, it) })
}
lineSeries {
series(x = telemetries.map { it.time }, y = telemetries.map { retrieveVoltage(selectedChannel, it) })
}
}
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
VOLTAGE_COLOR,
minValue = voltageMin,
maxValue = voltageMax,
)
}
Spacer(modifier = Modifier.height(16.dp))
GenericMetricChart(
modelProducer = modelProducer,
modifier = modifier.padding(8.dp),
layers =
listOf(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
),
),
startAxis =
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
),
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
Legend(legendData = LEGEND_DATA, displayInfoIcon = false)
Spacer(modifier = Modifier.height(16.dp))
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PowerMetricsCard(telemetry: Telemetry) {
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
PowerChannelColumn(

View file

@ -16,8 +16,8 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -29,12 +29,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.rememberScrollState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -44,61 +45,57 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.request_telemetry
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.rssi_definition
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.component.SnrAndRssi
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
import org.meshtastic.proto.MeshProtos.MeshPacket
@Suppress("MagicNumber")
private enum class Metric(val color: Color, val min: Float, val max: Float) {
SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */
RSSI(Color.Blue, -140f, -20f),
;
/** Difference between the metrics `max` and `min` values. */
fun difference() = max - min
private enum class SignalMetric(val color: Color) {
SNR(Color.Green),
RSSI(Color.Blue),
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.rssi, color = Metric.RSSI.color, environmentMetric = null),
LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null),
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null),
)
@Suppress("LongMethod")
@ -107,8 +104,12 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.signalMetricsFiltered(selectedTimeFrame)
val data = state.signalMetrics
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
@ -125,6 +126,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.signal_quality),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -134,7 +136,10 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) {
androidx.compose.material3.Icon(
imageVector = MeshtasticIcons.Refresh,
contentDescription = null,
contentDescription =
stringResource(Res.string.signal_quality) +
" " +
stringResource(Res.string.request_telemetry),
)
}
}
@ -159,20 +164,33 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
SignalMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = data.indexOfFirst { it.rxTime.toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, meshPacket ->
SignalMetricsCard(
meshPacket = meshPacket,
isSelected = meshPacket.rxTime.toDouble() == selectedX,
onClick = {
selectedX = meshPacket.rxTime.toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x(meshPacket.rxTime.toDouble(), SCROLL_BIAS),
)
}
},
)
}
}
}
}
@ -183,126 +201,115 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
ChartHeader(amount = meshPackets.size)
if (meshPackets.isEmpty()) {
return
}
val (oldest, newest) =
remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) }
val timeDiff = newest.rxTime - oldest.rxTime
val modelProducer = remember { CartesianChartModelProducer() }
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by
remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
LaunchedEffect(meshPackets) {
modelProducer.runTransaction {
/* Use separate lineSeries calls to associate them with different vertical axes */
lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxRssi }) }
lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxSnr }) }
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate visible width based on actual weight distribution
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val snrDiff = Metric.SNR.difference()
val rssiDiff = Metric.RSSI.difference()
Row {
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.RSSI.color,
minValue = Metric.RSSI.min,
maxValue = Metric.RSSI.max,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(
modifier.width(dp),
oldest = oldest.rxTime,
newest = newest.rxTime,
selectedTime.lineInterval(),
)
/* Plot SNR and RSSI */
Canvas(modifier = modifier.width(dp)) {
val width = size.width
/* Plot */
for (packet in meshPackets) {
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
val x = xRatio * width
/* SNR */
plotPoint(
drawContext = drawContext,
color = Metric.SNR.color,
x = x,
value = packet.rxSnr - Metric.SNR.min,
divisor = snrDiff,
)
/* RSSI */
plotPoint(
drawContext = drawContext,
color = Metric.RSSI.color,
x = x,
value = packet.rxRssi - Metric.RSSI.min,
divisor = rssiDiff,
)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color.copy(alpha = 1f) == rssiColor) {
"RSSI: %.0f dBm".format(value)
} else {
"SNR: %.1f dB".format(value)
}
}
}
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Metric.SNR.color,
minValue = Metric.SNR.min,
maxValue = Metric.SNR.max,
},
)
}
Spacer(modifier = Modifier.height(16.dp))
GenericMetricChart(
modelProducer = modelProducer,
modifier = modifier.padding(8.dp),
layers =
listOf(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP),
),
verticalAxisPosition = Axis.Position.Vertical.End,
),
),
startAxis =
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
),
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(
label = ChartStyling.rememberAxisLabel(),
valueFormatter = CommonCharts.dynamicTimeFormatter,
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
labelRotationDegrees = 45f,
),
marker = marker,
selectedX = selectedX,
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket) {
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rxTime * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}

View file

@ -20,21 +20,13 @@ import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -48,7 +40,6 @@ 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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@ -71,13 +62,13 @@ import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.toMessageRes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
import org.meshtastic.core.strings.traceroute_direct
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_hops
import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.traceroute_time_and_text
@ -86,7 +77,6 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.Group
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
@ -153,6 +143,7 @@ fun TracerouteLogScreen(
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
MainAppBar(
title = state.node?.user?.longName ?: "",
subtitle = stringResource(Res.string.traceroute_log),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -233,9 +224,10 @@ fun TracerouteLogScreen(
}
Box {
TracerouteItem(
MetricLogItem(
icon = icon,
text = stringResource(Res.string.traceroute_time_and_text, time, text),
contentDescription = stringResource(Res.string.traceroute),
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
val dialogMessage =
@ -322,37 +314,6 @@ private fun TracerouteLogDialogs(
}
}
@Composable
private fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
@ -424,5 +385,11 @@ private fun TracerouteItemPreview() {
System.currentTimeMillis(),
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
AppTheme { TracerouteItem(icon = MeshtasticIcons.Group, text = "$time - Direct") }
AppTheme {
MetricLogItem(
icon = MeshtasticIcons.Group,
text = "$time - Direct",
contentDescription = stringResource(Res.string.traceroute),
)
}
}

View file

@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChargingStation
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Map
import androidx.compose.material.icons.rounded.Memory
@ -32,11 +33,12 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.host_metrics_log
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.node_map
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.position_log
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.sig_metrics_log
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
@ -47,9 +49,10 @@ enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val rou
NODE_MAP(Res.string.node_map, Icons.Rounded.Map, { NodeDetailRoutes.NodeMap(it) }),
POSITIONS(Res.string.position_log, Icons.Rounded.LocationOn, { NodeDetailRoutes.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Icons.Rounded.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }),
SIGNAL(Res.string.sig_metrics_log, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
SIGNAL(Res.string.signal_quality, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
POWER(Res.string.power_metrics_log, Icons.Rounded.Power, { NodeDetailRoutes.PowerMetrics(it) }),
TRACEROUTE(Res.string.traceroute_log, MeshtasticIcons.Route, { NodeDetailRoutes.TracerouteLog(it) }),
NEIGHBOR_INFO(Res.string.neighbor_info, Icons.Rounded.Groups, { NodeDetailRoutes.NeighborInfoLog(it) }),
HOST(Res.string.host_metrics_log, Icons.Rounded.Memory, { NodeDetailRoutes.HostMetricsLog(it) }),
PAX(Res.string.pax_metrics_log, MeshtasticIcons.Paxcount, { NodeDetailRoutes.PaxMetrics(it) }),
}

View file

@ -16,24 +16,13 @@
*/
package org.meshtastic.feature.node.model
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.forty_eight_hours
import org.meshtastic.core.strings.four_weeks
import org.meshtastic.core.strings.max
import org.meshtastic.core.strings.one_week
import org.meshtastic.core.strings.twenty_four_hours
import org.meshtastic.core.strings.two_weeks
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import java.util.concurrent.TimeUnit
data class MetricsState(
val isLocal: Boolean = false,
@ -48,9 +37,10 @@ data class MetricsState(
val hostMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshLog> = emptyList(),
val neighborInfoRequests: List<MeshLog> = emptyList(),
val neighborInfoResults: List<MeshLog> = emptyList(),
val positionLogs: List<MeshProtos.Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
val isLocalDevice: Boolean = false,
val firmwareEdition: MeshProtos.FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
@ -66,84 +56,15 @@ data class MetricsState(
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
fun hasNeighborInfoLogs() = neighborInfoRequests.isNotEmpty()
fun hasPositionLogs() = positionLogs.isNotEmpty()
fun hasHostMetrics() = hostMetrics.isNotEmpty()
fun hasPaxMetrics() = paxMetrics.isNotEmpty()
fun deviceMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return deviceMetrics.filter { it.time >= oldestTime }
}
fun signalMetricsFiltered(timeFrame: TimeFrame): List<MeshProtos.MeshPacket> {
val oldestTime = timeFrame.calculateOldestTime()
return signalMetrics.filter { it.rxTime >= oldestTime }
}
fun powerMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return powerMetrics.filter { it.time >= oldestTime }
}
companion object {
val Empty = MetricsState()
}
}
/** Supported time frames used to display data. */
@Suppress("MagicNumber")
enum class TimeFrame(val seconds: Long, val strRes: StringResource) {
TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), Res.string.twenty_four_hours),
FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), Res.string.forty_eight_hours),
ONE_WEEK(TimeUnit.DAYS.toSeconds(7), Res.string.one_week),
TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), Res.string.two_weeks),
FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), Res.string.four_weeks),
MAX(0L, Res.string.max),
;
fun calculateOldestTime(): Long = if (this == MAX) {
MAX.seconds
} else {
System.currentTimeMillis() / 1000 - this.seconds
}
/**
* The time interval to draw the vertical lines representing time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal,
-> TimeUnit.DAYS.toSeconds(1)
else -> TimeUnit.DAYS.toSeconds(7)
}
/** Used to detect a significant time separation between [TelemetryProtos.Telemetry]s. */
fun timeThreshold(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
else -> TimeUnit.DAYS.toSeconds(1)
}
/**
* Calculates the needed [androidx.compose.ui.unit.Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {
val timePerScreen = this.lineInterval()
val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}

View file

@ -19,14 +19,13 @@ package org.meshtastic.feature.node.metrics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.Telemetry
class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsFiltered correctly calculates times`() {
fun `environmentMetricsForGraphing correctly calculates times`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val metrics =
listOf(
@ -44,37 +43,14 @@ class EnvironmentMetricsStateTest {
.build(),
)
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
val result = state.environmentMetricsForGraphing()
assertEquals(now - 100, result.times.first)
assertEquals(now, result.times.second)
}
@Test
fun `environmentMetricsFiltered ignores invalid timestamps`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val metrics =
listOf(
Telemetry.newBuilder()
.setTime(0)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
.build(),
Telemetry.newBuilder()
.setTime(now)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
.build(),
)
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
// Only the valid timestamp should be considered for filters
assertEquals(now, result.times.first)
assertEquals(now, result.times.second)
assertEquals(1, result.metrics.size)
}
@Test
fun `environmentMetricsFiltered handles valid zero temperatures`() {
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val metrics =
listOf(
@ -84,7 +60,7 @@ class EnvironmentMetricsStateTest {
.build(),
)
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
val result = state.environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
assertEquals(0.0f, result.rightMinMax.first, 0.01f)

View file

@ -43,6 +43,7 @@ devtools-ksp = "2.3.5"
markdownRenderer = "0.39.1"
osmdroid-android = "6.1.20"
protobuf = "4.33.4"
vico = "3.0.0-beta.3"
[libraries]
@ -181,6 +182,9 @@ streamsupport-minifuture = { module = "net.sourceforge.streamsupport:streamsuppo
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version = "4.3.0" }
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
vico-compose-m2 = { group = "com.patrykandpatrick.vico", name = "compose-m2", version.ref = "vico" }
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
# Build Logic
android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }