From 9c0e9b82d65e3117dc1b215a1fad002ecf361f6c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:44:59 -0500 Subject: [PATCH] feat(charts): adopt Vico best practices, add sensor data, and migrate TracerouteLog (#5026) --- .../core/model/RouteDiscoveryTest.kt | 133 ++++++ .../composeResources/values/strings.xml | 28 ++ .../meshtastic/core/ui/theme/CustomColors.kt | 5 + .../feature/node/metrics/BaseMetricChart.kt | 111 ++++- .../feature/node/metrics/ChartStyling.kt | 161 ++++--- .../feature/node/metrics/CommonCharts.kt | 107 +++-- .../feature/node/metrics/DeviceMetrics.kt | 37 +- .../feature/node/metrics/EnvironmentCharts.kt | 98 +++-- .../node/metrics/EnvironmentMetrics.kt | 120 +++++- .../node/metrics/EnvironmentMetricsState.kt | 40 +- .../feature/node/metrics/HostMetricsChart.kt | 232 ++++++++++ .../feature/node/metrics/HostMetricsLog.kt | 295 +++++++------ .../feature/node/metrics/PaxMetrics.kt | 26 +- .../feature/node/metrics/PowerMetrics.kt | 127 ++++-- .../feature/node/metrics/SignalMetrics.kt | 19 +- .../feature/node/metrics/TracerouteChart.kt | 263 ++++++++++++ .../feature/node/metrics/TracerouteLog.kt | 406 ++++++++++++------ .../node/navigation/NodesNavigation.kt | 2 +- .../feature/node/metrics/FormatBytesTest.kt | 94 ++++ .../node/metrics/TracerouteChartTest.kt | 265 ++++++++++++ 20 files changed, 2062 insertions(+), 507 deletions(-) create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt new file mode 100644 index 000000000..a89f2b886 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.core.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests for [evaluateTracerouteMapAvailability] — the pure function that determines whether a traceroute can be + * visualised on a map based on node position data. + */ +@Suppress("MagicNumber") +class RouteDiscoveryTest { + + @Test + fun ok_whenAllNodesHavePositions() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + val positioned = setOf(1, 2, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun ok_whenEndpointsPositioned_andIntermediateNot() { + // Endpoints (1 and 3) are positioned, intermediate (2) is not + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun missingEndpoints_whenForwardStartMissing() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + // Node 1 (forward start / back end) is missing from positioned set + val positioned = setOf(2, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun missingEndpoints_whenForwardEndMissing() { + val forward = listOf(1, 2, 3) + val back = listOf(3, 2, 1) + // Node 3 (forward end / back start) is missing + val positioned = setOf(1, 2) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun noMappableNodes_whenNonePositioned() { + val forward = listOf(1, 2, 3) + val back = emptyList() + // No node in the routes has a position — but first check endpoints + // Endpoints 1 and 3 are missing → MissingEndpoints takes precedence + val positioned = emptySet() + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.MissingEndpoints, result) + } + + @Test + fun noMappableNodes_whenEmptyRoutes() { + // Empty routes → no endpoints, no related nodes → NoMappableNodes + val result = evaluateTracerouteMapAvailability(emptyList(), emptyList(), setOf(1, 2)) + + assertEquals(TracerouteMapAvailability.NoMappableNodes, result) + } + + @Test + fun ok_whenOnlyForwardRoute_endpointsPositioned() { + // Only forward route, no return route + val forward = listOf(1, 2, 3) + val back = emptyList() + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun missingEndpoints_whenReturnRouteEndpointMissing() { + // Return route has different endpoints than forward (asymmetric path) + val forward = listOf(1, 2, 3) + val back = listOf(3, 4, 1) + // All forward endpoints (1, 3) are positioned, but checking back endpoints too + // back first = 3 (positioned), back last = 1 (positioned) → all endpoints OK + val positioned = setOf(1, 3) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + @Test + fun directRoute_withTwoNodes() { + val forward = listOf(1, 2) + val back = listOf(2, 1) + val positioned = setOf(1, 2) + + val result = evaluateTracerouteMapAvailability(forward, back, positioned) + + assertEquals(TracerouteMapAvailability.Ok, result) + } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index b746ce3e5..d08b073ea 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -479,6 +479,17 @@ %1$s - %2$s Route traced toward destination:\n\n Route traced back to us:\n\n + Forward Hops + Return Hops + Round Trip + No Response + Load 1m + Load 5m + Load 15m + One-minute system load average + Five-minute system load average + Fifteen-minute system load average + Available system memory in bytes 1H 24H 48H @@ -487,6 +498,10 @@ 4W 1M Max + Min + Avg + Expand chart + Collapse chart Unknown Age Copy Alert Bell Character! @@ -500,6 +515,11 @@ Channel 1 Channel 2 Channel 3 + Channel 4 + Channel 5 + Channel 6 + Channel 7 + Channel 8 Current Voltage Are you sure? @@ -782,6 +802,14 @@ Distance Lux Wind + Wind Speed + Wind Gust + Wind Lull + Wind Dir + Rain (1h) + Rain (24h) + IR Lux + White Lux Weight Radiation diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 38338a555..240c01503 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -55,6 +55,11 @@ object GraphColors { val Red = Color(0xFFE91E63) val Blue = Color(0xFF2196F3) val Green = Color(0xFF4CAF50) + val Teal = Color(0xFF009688) + val Amber = Color(0xFFFFC107) + val Lime = Color(0xFFCDDC39) + val Indigo = Color(0xFF3F51B5) + val DeepOrange = Color(0xFFFF5722) } object StatusColors { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index b31061ded..e0e90d252 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -16,6 +16,10 @@ */ package org.meshtastic.feature.node.metrics +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,20 +30,27 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.List +import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.AutoScrollCondition import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.FadingEdges import com.patrykandpatrick.vico.compose.cartesian.Scroll import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.Zoom @@ -47,19 +58,27 @@ 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.decoration.Decoration 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.rememberFadingEdges import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.avg +import org.meshtastic.core.resources.collapse_chart +import org.meshtastic.core.resources.expand_chart import org.meshtastic.core.resources.info import org.meshtastic.core.resources.logs +import org.meshtastic.core.resources.max +import org.meshtastic.core.resources.min import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh @@ -67,6 +86,9 @@ import org.meshtastic.core.ui.icon.Refresh /** * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point * selection synchronization. + * + * Uses [FadingEdges] to indicate scrollable content beyond the visible area, and accepts optional [Decoration]s for + * reference threshold lines/bands. */ @Composable fun GenericMetricChart( @@ -77,6 +99,7 @@ fun GenericMetricChart( endAxis: VerticalAxis? = null, bottomAxis: HorizontalAxis? = null, marker: CartesianMarker? = null, + decorations: List = emptyList(), selectedX: Double? = null, onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), @@ -105,6 +128,8 @@ fun GenericMetricChart( marker = marker, markerVisibilityListener = markerVisibilityListener, persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, + fadingEdges = rememberFadingEdges(), + decorations = decorations, ), modelProducer = modelProducer, modifier = modifier, @@ -115,31 +140,83 @@ fun GenericMetricChart( /** * An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for - * narrow screens (phones). + * narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available + * space. */ @Composable fun AdaptiveMetricLayout( chartPart: @Composable (Modifier) -> Unit, listPart: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, + isChartExpanded: Boolean = false, ) { BoxWithConstraints(modifier = modifier) { val isExpanded = maxWidth >= 600.dp if (isExpanded) { Row(modifier = Modifier.fillMaxSize()) { chartPart(Modifier.weight(1f).fillMaxHeight()) - listPart(Modifier.weight(1f).fillMaxHeight()) + AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) { + listPart(Modifier.weight(1f).fillMaxHeight()) + } } } else { Column(modifier = Modifier.fillMaxSize()) { - chartPart(Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) - listPart(Modifier.fillMaxWidth().weight(1f)) + chartPart( + if (isChartExpanded) { + Modifier.fillMaxWidth().weight(1f) + } else { + Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f) + }, + ) + AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) { + listPart(Modifier.fillMaxWidth().weight(1f)) + } } } } } -/** A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and synchronization. */ +/** + * Displays a compact row of min/max/avg statistics for a metric. Intended to be placed between the chart controls and + * the chart itself. + */ +@Composable +fun MetricSummaryRow(values: List, label: String = "", modifier: Modifier = Modifier) { + if (values.isEmpty()) return + val minVal = values.min() + val maxVal = values.max() + val avgVal = values.average().toFloat() + + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + SummaryChip(label = stringResource(Res.string.min), value = formatString("%.1f %s", minVal, label)) + SummaryChip(label = stringResource(Res.string.avg), value = formatString("%.1f %s", avgVal, label)) + SummaryChip(label = stringResource(Res.string.max), value = formatString("%.1f %s", maxVal, label)) + } +} + +@Composable +private fun SummaryChip(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface) + } +} + +/** + * A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list + * synchronisation. + * + * @param extraActions Additional composable actions rendered in the app bar before the expand/collapse toggle (e.g. a + * cooldown traceroute button). + */ @Composable @Suppress("LongMethod") fun BaseMetricScreen( @@ -151,14 +228,20 @@ fun BaseMetricScreen( timeProvider: (T) -> Double, infoData: List = emptyList(), onRequestTelemetry: (() -> Unit)? = null, + extraActions: @Composable () -> Unit = {}, chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit, listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit, controlPart: @Composable () -> Unit = {}, ) { var displayInfoDialog by remember { mutableStateOf(false) } + var isChartExpanded by remember { mutableStateOf(false) } val lazyListState = rememberLazyListState() - val vicoScrollState = rememberVicoScrollState() + val vicoScrollState = + rememberVicoScrollState( + autoScroll = Scroll.Absolute.End, + autoScrollCondition = AutoScrollCondition.OnModelGrowth, + ) val coroutineScope = rememberCoroutineScope() var selectedX by remember { mutableStateOf(null) } @@ -172,6 +255,21 @@ fun BaseMetricScreen( canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { + extraActions() + IconButton(onClick = { isChartExpanded = !isChartExpanded }) { + Icon( + imageVector = + if (isChartExpanded) { + Icons.AutoMirrored.Rounded.List + } else { + Icons.Rounded.BarChart + }, + contentDescription = + stringResource( + if (isChartExpanded) Res.string.collapse_chart else Res.string.expand_chart, + ), + ) + } if (infoData.isNotEmpty()) { IconButton(onClick = { displayInfoDialog = true }) { Icon(imageVector = Icons.Rounded.Info, contentDescription = stringResource(Res.string.info)) @@ -198,6 +296,7 @@ fun BaseMetricScreen( controlPart() AdaptiveMetricLayout( + isChartExpanded = isChartExpanded, chartPart = { modifier -> chartPart(modifier, selectedX, vicoScrollState) { x -> selectedX = x diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt index 1624f1673..81709c6fd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -19,6 +19,7 @@ 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.runtime.remember import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -28,6 +29,8 @@ 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.decoration.Decoration +import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker @@ -37,6 +40,7 @@ import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesi 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.Position import com.patrykandpatrick.vico.compose.common.component.ShapeComponent import com.patrykandpatrick.vico.compose.common.component.TextComponent import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent @@ -46,121 +50,94 @@ 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. + * + * **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)): + * - Default to thin lines **without** point markers to avoid clutter on dense timeseries. + * - Show a single dot only at the marker/cursor position (handled by [rememberMarker]). + * - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point. + * - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data. */ +@Suppress("TooManyFunctions") 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. + * Creates a clean timeseries line — thin, smooth, with **no** point markers. This is the default style recommended + * by Oscar's UX guidance: "thin lines, and maybe a dot where the cursor is." * * @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 = + fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_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, - ), - ), + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = LineCartesianLayer.Interpolator.catmullRom(), ) /** - * Creates a line with a gradient fill effect. The gradient goes from the line color to transparent. + * Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The + * gradient goes from the line color at ~30% opacity to near-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 { + fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line { val gradientBrush = - Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.1f))) + Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f))) 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), + interpolator = LineCartesianLayer.Interpolator.catmullRom(), ) } /** - * Creates a bold line suitable for highlighting primary metrics. + * Creates a bold line suitable for highlighting the primary metric in a multi-series chart. * * @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) + fun createBoldLine(lineColor: Color): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP) /** - * Creates a subtle line suitable for secondary metrics. + * Creates a subtle line suitable for secondary metrics that should not dominate the chart. * * @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) + fun createSubtleLine(lineColor: Color): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THIN_LINE_WIDTH_DP) + + /** + * Creates a dashed secondary line. Useful for distinguishing two metrics that share the same axis without relying + * on colour alone. + * + * @param lineColor The color of the dashed line + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = + LineCartesianLayer.LineStroke.Dashed( + thickness = THIN_LINE_WIDTH_DP.dp, + dashLength = 6.dp, + gapLength = 3.dp, + ), + interpolator = LineCartesianLayer.Interpolator.catmullRom(), + ) /** * Gets Material 3 theme-aware colors with opacity. Useful for creating color variants while respecting the current @@ -172,6 +149,38 @@ object ChartStyling { */ fun createThemedColor(baseColor: Color, alpha: Float = 1f): Color = baseColor.copy(alpha = alpha) + /** + * Creates a [HorizontalLine] decoration for a reference threshold (e.g. battery low, pressure normal). + * + * @param y The y-value to draw the line at + * @param color The color of the threshold line + * @param label Optional label text for the line + */ + @Composable + fun rememberThresholdLine(y: Double, color: Color, label: String? = null): Decoration { + val line = rememberLineComponent(fill = Fill(color.copy(alpha = 0.4f)), thickness = 1.dp) + val labelComponent = + if (label != null) { + rememberTextComponent( + style = + TextStyle(color = color.copy(alpha = 0.7f), fontSize = 9.sp, fontWeight = FontWeight.Medium), + padding = Insets(horizontal = 4.dp, vertical = 1.dp), + ) + } else { + null + } + return remember(y, color, label) { + HorizontalLine( + y = { y }, + line = line, + labelComponent = labelComponent, + label = { label ?: "" }, + horizontalLabelPosition = Position.Horizontal.End, + verticalLabelPosition = Position.Vertical.Top, + ) + } + } + /** * Creates and remembers a default [CartesianMarker] styled for the Meshtastic theme. * @@ -250,18 +259,6 @@ object ChartStyling { } } - /** - * Creates a standard [com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer] with optimized - * spacing. - */ - fun rememberItemPlacer( - spacing: Int = 50, - ): com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer = - com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer.aligned( - spacing = { spacing }, - addExtremeLabelPadding = true, - ) - /** * Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis * labels. diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index 5d8a172bc..495fee2c7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -36,6 +36,7 @@ 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.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -50,7 +51,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -84,18 +86,30 @@ object CommonCharts { @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. */ + /** + * A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span + * ([CartesianRanges.xLength]). + * + * Since chart data is already filtered by [TimeFrame], `xLength` approximates the visible window. Vico's formatter + * receives [CartesianMeasuringContext] during measurement passes — **not** [CartesianDrawingContext] — so + * `context.zoom` is unavailable and we intentionally avoid it. + * + * | Data span | Format | Example | + * |-----------|------------------------|------------------| + * | ≤ 1 hour | Time with seconds | 3:45:12 PM | + * | ≤ 2 days | Time only | 3:45 PM | + * | ≤ 14 days | Date + time (two-line) | 4/9/26 ↵ 3:45 PM | + * | > 14 days | Date only | 4/9/26 | + */ val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ -> val timestampMillis = (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 dataSpanSeconds = context.ranges.xLength when { - visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> DateFormatter.formatTimeWithSeconds(timestampMillis) - visibleSpan <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis) - visibleSpan <= 14.days.inWholeSeconds -> { - // < 2 weeks visible: separate date and time with a newline + dataSpanSeconds <= TimeConstants.ONE_HOUR.inWholeSeconds -> + DateFormatter.formatTimeWithSeconds(timestampMillis) + dataSpanSeconds <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis) + dataSpanSeconds <= 14.days.inWholeSeconds -> { val dateStr = DateFormatter.formatDate(timestampMillis) val timeStr = DateFormatter.formatTime(timestampMillis) "$dateStr\n$timeStr" @@ -105,6 +119,23 @@ object CommonCharts { } fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis) + + /** + * Shared bottom time axis used by all metric chart screens. + * + * Uses `spacing = 1` with `addExtremeLabelPadding = true` so Vico's built-in auto-thinning controls label density — + * it measures label widths and automatically skips labels when they would overlap, adapting to both zoom level and + * screen width. + */ + @Composable + fun rememberBottomTimeAxis(): HorizontalAxis = HorizontalAxis.rememberBottom( + label = ChartStyling.rememberAxisLabel(), + valueFormatter = dynamicTimeFormatter, + itemPlacer = HorizontalAxis.ItemPlacer.aligned(spacing = { 1 }, addExtremeLabelPadding = true), + labelRotationDegrees = LABEL_ROTATION_DEGREES, + ) + + private const val LABEL_ROTATION_DEGREES = 45f } data class LegendData( @@ -116,18 +147,46 @@ data class LegendData( data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) -/** Creates the legend that identifies the colors used for the graph. */ +/** + * Creates the legend that identifies the colors used for the graph. + * + * When [onToggle] is provided, each item renders as a Material 3 [FilterChip] so users can tap to show/hide chart + * series. This provides proper M3 affordance (selected state styling, ripple, accessibility semantics). When [onToggle] + * is null, a compact read-only legend is shown instead. + */ @OptIn(ExperimentalLayoutApi::class) @Composable -fun Legend(legendData: List, modifier: Modifier = Modifier) { +fun Legend( + legendData: List, + modifier: Modifier = Modifier, + hiddenSet: Set = emptySet(), + onToggle: ((Int) -> Unit)? = null, +) { FlowRow( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - legendData.forEach { data -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { - LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine) + legendData.forEachIndexed { index, data -> + val isVisible = index !in hiddenSet + if (onToggle != null) { + FilterChip( + selected = isVisible, + onClick = { onToggle(index) }, + label = { Text(stringResource(data.nameRes)) }, + leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, + modifier = Modifier.padding(horizontal = 2.dp), + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) { + LegendIndicator(color = data.color, isLine = data.isLine) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(data.nameRes), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelSmall.fontSize, + ) + } } } } @@ -180,8 +239,9 @@ fun LegendInfoDialog(infoData: List, onDismiss: () -> Unit) { ) } +/** Draws a small colored line segment or circle to identify a chart series. */ @Composable -private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { +fun LegendIndicator(color: Color, isLine: Boolean = false) { Canvas(modifier = Modifier.size(height = 4.dp, width = if (isLine) 16.dp else 4.dp)) { if (isLine) { drawLine( @@ -195,12 +255,6 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { drawCircle(color = color) } } - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = text, - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelSmall.fontSize, - ) } @Composable @@ -213,8 +267,13 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) { private fun LegendPreview() { val data = listOf( - LegendData(nameRes = Res.string.rssi, color = Color.Red), - LegendData(nameRes = Res.string.snr, color = Color.Green), + LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true), + LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true), ) - Legend(legendData = data) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Read-only legend + Legend(legendData = data) + // Toggleable legend + Legend(legendData = data, hiddenSet = setOf(1), onToggle = {}) + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 78f04396f..73b415035 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -53,9 +53,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -111,13 +111,13 @@ private val LEGEND_DATA = LegendData( nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, - isLine = false, + isLine = true, environmentMetric = null, ), LegendData( nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, - isLine = false, + isLine = true, environmentMetric = null, ), ) @@ -188,6 +188,10 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { onTimeFrameSelected = viewModel::setTimeFrame, modifier = Modifier.padding(horizontal = 16.dp), ) + if (hasBattery) { + val batteryValues = remember(data) { data.mapNotNull { it.device_metrics?.battery_level?.toFloat() } } + MetricSummaryRow(values = batteryValues, label = "%") + } }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> DeviceMetricsChart( @@ -260,19 +264,19 @@ private fun DeviceMetricsChart( val batteryStyle = if (batteryData.isNotEmpty()) { - ChartStyling.createBoldLine(batteryColor, ChartStyling.MEDIUM_POINT_SIZE_DP) + ChartStyling.createBoldLine(batteryColor) } else { null } val chUtilStyle = if (chUtilData.isNotEmpty()) { - ChartStyling.createPointOnlyLine(chUtilColor, ChartStyling.LARGE_POINT_SIZE_DP) + ChartStyling.createSubtleLine(chUtilColor) } else { null } val airUtilStyle = if (airUtilData.isNotEmpty()) { - ChartStyling.createPointOnlyLine(airUtilColor, ChartStyling.LARGE_POINT_SIZE_DP) + ChartStyling.createDashedLine(airUtilColor) } else { null } @@ -322,6 +326,7 @@ private fun DeviceMetricsChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), ) } else { null @@ -332,10 +337,7 @@ private fun DeviceMetricsChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine( - lineColor = voltageColor, - pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, - ), + ChartStyling.createGradientLine(lineColor = voltageColor), ), verticalAxisPosition = Axis.Position.Vertical.End, ) @@ -346,6 +348,12 @@ private fun DeviceMetricsChart( val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) } if (layers.isNotEmpty()) { + val decorations = buildList { + if (leftLayer != null) { + add(ChartStyling.rememberThresholdLine(y = 20.0, color = batteryColor, label = "20%")) + } + } + GenericMetricChart( modelProducer = modelProducer, modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), @@ -368,14 +376,9 @@ private fun DeviceMetricsChart( } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, + decorations = decorations, selectedX = selectedX, onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index 6470e24dc..cd8a4ab3f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -21,14 +21,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +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.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.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -39,10 +42,12 @@ import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature import org.meshtastic.core.resources.temperature import org.meshtastic.core.resources.uv_lux +import org.meshtastic.core.resources.wind_speed import org.meshtastic.proto.Telemetry @Suppress("MagicNumber") @@ -87,6 +92,18 @@ private val LEGEND_DATA_2 = isLine = true, environmentMetric = Environment.UV_LUX, ), + LegendData( + nameRes = Res.string.wind_speed, + color = Environment.WIND_SPEED.color, + isLine = true, + environmentMetric = Environment.WIND_SPEED, + ), + LegendData( + nameRes = Res.string.radiation, + color = Environment.RADIATION.color, + isLine = true, + environmentMetric = Environment.RADIATION, + ), ) private val LEGEND_DATA_3 = @@ -128,10 +145,21 @@ fun EnvironmentMetricsChart( (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] } + + // Legend toggle state: tracks indices into allLegendData that are hidden + var hiddenIndices by remember { mutableStateOf(emptySet()) } + val hiddenMetrics = + remember(hiddenIndices, allLegendData) { + hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.environmentMetric }.toSet() + } + val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } + val showPressure = + shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics val pressureData = - remember(telemetries) { + remember(telemetries, showPressure) { + if (!showPressure) return@remember emptyList() telemetries.filter { val v = Environment.BAROMETRIC_PRESSURE.getValue(it) it.time != 0 && v != null && !v.isNaN() @@ -139,9 +167,10 @@ fun EnvironmentMetricsChart( } val otherMetrics = - remember(telemetries, shouldPlot) { + remember(telemetries, shouldPlot, hiddenMetrics) { Environment.entries.filter { metric -> metric != Environment.BAROMETRIC_PRESSURE && + metric !in hiddenMetrics && shouldPlot[metric.ordinal] && telemetries.any { val v = metric.getValue(it) @@ -163,7 +192,7 @@ fun EnvironmentMetricsChart( LaunchedEffect(pressureData, otherMetricsData) { modelProducer.runTransaction { /* Pressure on its own layer/axis */ - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { lineSeries { series( x = pressureData.map { it.time }, @@ -193,28 +222,40 @@ fun EnvironmentMetricsChart( ) val layers = mutableListOf() - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { layers.add( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine( - Environment.BAROMETRIC_PRESSURE.color, - ChartStyling.MEDIUM_POINT_SIZE_DP, - ), + ChartStyling.createGradientLine(Environment.BAROMETRIC_PRESSURE.color), ), verticalAxisPosition = Axis.Position.Vertical.Start, + // Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale, + // otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude. + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0), ), ) } otherMetrics.forEach { metric -> + // Radiation and wind speed use fixed minY=0 per Oscar's UX guidance + val rangeProvider = + when (metric) { + Environment.RADIATION, + Environment.WIND_SPEED, + -> CartesianLayerRangeProvider.fixed(minY = 0.0) + else -> null + } + val lineStyle = + if (metric == Environment.WIND_SPEED) { + ChartStyling.createDashedLine(metric.color) + } else { + ChartStyling.createStyledLine(metric.color) + } layers.add( rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), + lineProvider = LineCartesianLayer.LineProvider.series(lineStyle), verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(), ), ) } @@ -227,7 +268,7 @@ fun EnvironmentMetricsChart( modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), layers = layers, startAxis = - if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) { + if (showPressure && pressureData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color), valueFormatter = { _, value, _ -> formatString("%.0f hPa", value) }, @@ -236,17 +277,15 @@ fun EnvironmentMetricsChart( null }, endAxis = - VerticalAxis.rememberEnd( - label = ChartStyling.rememberAxisLabel(color = endAxisColor), - valueFormatter = { _, value, _ -> formatString("%.0f", value) }, - ), - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + if (otherMetrics.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = endAxisColor), + valueFormatter = { _, value, _ -> formatString("%.0f", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, @@ -254,6 +293,13 @@ fun EnvironmentMetricsChart( ) } - Legend(legendData = allLegendData, modifier = Modifier.padding(top = 0.dp)) + Legend( + legendData = allLegendData, + modifier = Modifier.padding(top = 0.dp), + hiddenSet = hiddenIndices, + onToggle = { index -> + hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index + }, + ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 863e09eec..ee830a08e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -59,11 +60,17 @@ import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.iaq_definition import org.meshtastic.core.resources.lux import org.meshtastic.core.resources.radiation +import org.meshtastic.core.resources.rainfall_1h +import org.meshtastic.core.resources.rainfall_24h import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature import org.meshtastic.core.resources.temperature import org.meshtastic.core.resources.uv_lux import org.meshtastic.core.resources.voltage +import org.meshtastic.core.resources.wind_direction +import org.meshtastic.core.resources.wind_gust +import org.meshtastic.core.resources.wind_lull +import org.meshtastic.core.resources.wind_speed import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC @@ -93,6 +100,14 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un onTimeFrameSelected = viewModel::setTimeFrame, modifier = Modifier.padding(horizontal = 16.dp), ) + val tempValues = + remember(filteredTelemetries) { + filteredTelemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { t -> !t.isNaN() } } + } + if (tempValues.isNotEmpty()) { + val unit = if (state.isFahrenheit) "°F" else "°C" + MetricSummaryRow(values = tempValues, label = unit) + } }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> EnvironmentMetricsChart( @@ -341,8 +356,103 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics envMetrics.radiation?.let { radiation -> if (!radiation.isNaN() && radiation > 0f) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(Environment.RADIATION.color) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN() + val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN() + val hasLull = envMetrics.wind_lull != null && !envMetrics.wind_lull!!.isNaN() + + if (hasSpeed || hasGust || hasLull) { + Column(modifier = Modifier.fillMaxWidth()) { + if (hasSpeed) WindSpeedRow(envMetrics) + if (hasGust || hasLull) WindGustLullRow(envMetrics, hasGust, hasLull) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(Environment.WIND_SPEED.color) + Spacer(Modifier.width(4.dp)) + val dirText = + if (envMetrics.wind_direction != null) { + formatString( + "%s %.1f m/s (%s %d°)", + stringResource(Res.string.wind_speed), + envMetrics.wind_speed!!, + stringResource(Res.string.wind_direction), + envMetrics.wind_direction!!, + ) + } else { + formatString("%s %.1f m/s", stringResource(Res.string.wind_speed), envMetrics.wind_speed!!) + } + Text( + text = dirText, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + if (hasGust) { + Text( + text = formatString("%s %.1f m/s", stringResource(Res.string.wind_gust), envMetrics.wind_gust!!), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + if (hasLull) { + Text( + text = formatString("%s %.1f m/s", stringResource(Res.string.wind_lull), envMetrics.wind_lull!!), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN() + val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN() + + if (has1h || has24h) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + if (has1h) { Text( - text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation), + text = formatString("%s %.1f mm", stringResource(Res.string.rainfall_1h), envMetrics.rainfall_1h!!), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + if (has24h) { + Text( + text = + formatString("%s %.1f mm", stringResource(Res.string.rainfall_24h), envMetrics.rainfall_24h!!), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -406,6 +516,8 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa VoltageCurrentDisplay(envMetrics) RadiationDisplay(envMetrics) + WindDisplay(envMetrics) + RainfallDisplay(envMetrics) } } @@ -427,6 +539,12 @@ private fun PreviewEnvironmentMetricsContent() { iaq = 100, radiation = 0.15f, gas_resistance = 1200.0f, + wind_speed = 5.2f, + wind_direction = 225, + wind_gust = 8.1f, + wind_lull = 2.3f, + rainfall_1h = 1.5f, + rainfall_24h = 12.3f, ) val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics) MaterialTheme { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index 1d0524500..dda094e21 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -23,10 +23,12 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue +import org.meshtastic.core.ui.theme.GraphColors.Lime import org.meshtastic.core.ui.theme.GraphColors.Orange 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.Teal import org.meshtastic.proto.Telemetry @Suppress("MagicNumber") @@ -59,6 +61,12 @@ enum class Environment(val color: Color) { }, UV_LUX(Orange) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.uv_lux + }, + WIND_SPEED(Teal) { + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed + }, + RADIATION(Lime) { + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation }, ; abstract fun getValue(telemetry: Telemetry): Float? @@ -114,9 +122,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Relative Humidity - val humidities = telemetries.mapNotNull { - it.environment_metrics?.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f } - } + val humidities = + telemetries.mapNotNull { it.environment_metrics?.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f } } if (humidities.isNotEmpty()) { minValues.add(humidities.minOf { it }) maxValues.add(humidities.maxOf { it }) @@ -124,9 +131,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Soil Temperature - val soilTemperatures = telemetries.mapNotNull { - it.environment_metrics?.soil_temperature?.takeIf { !it.isNaN() } - } + val soilTemperatures = + telemetries.mapNotNull { it.environment_metrics?.soil_temperature?.takeIf { !it.isNaN() } } if (soilTemperatures.isNotEmpty()) { var minSoilTemperatureValue = soilTemperatures.minOf { it } var maxSoilTemperatureValue = soilTemperatures.maxOf { it } @@ -140,9 +146,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Soil Moisture - val soilMoistures = telemetries.mapNotNull { - it.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE } - } + val soilMoistures = + telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE } } if (soilMoistures.isNotEmpty()) { minValues.add(soilMoistures.minOf { it.toFloat() }) maxValues.add(soilMoistures.maxOf { it.toFloat() }) @@ -183,6 +188,23 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp shouldPlot[Environment.UV_LUX.ordinal] = true } + // Wind Speed + val windSpeeds = telemetries.mapNotNull { it.environment_metrics?.wind_speed?.takeIf { !it.isNaN() } } + if (windSpeeds.isNotEmpty()) { + minValues.add(windSpeeds.minOf { it }) + maxValues.add(windSpeeds.maxOf { it }) + shouldPlot[Environment.WIND_SPEED.ordinal] = true + } + + // Radiation (uses separate fixed axis with minY=0 per Oscar's guidance) + val radiationValues = + telemetries.mapNotNull { it.environment_metrics?.radiation?.takeIf { !it.isNaN() && it > 0f } } + if (radiationValues.isNotEmpty()) { + minValues.add(radiationValues.minOf { it }) + maxValues.add(radiationValues.maxOf { it }) + shouldPlot[Environment.RADIATION.ordinal] = true + } + val min = if (minValues.isEmpty()) 0f else minValues.minOf { it } val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt new file mode 100644 index 000000000..f04121bca --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt @@ -0,0 +1,232 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider +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.meshtastic.core.common.util.formatString +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.free_memory +import org.meshtastic.core.resources.free_memory_description +import org.meshtastic.core.resources.load_15_min +import org.meshtastic.core.resources.load_15_min_description +import org.meshtastic.core.resources.load_1_min +import org.meshtastic.core.resources.load_1_min_description +import org.meshtastic.core.resources.load_5_min +import org.meshtastic.core.resources.load_5_min_description +import org.meshtastic.core.ui.theme.GraphColors +import org.meshtastic.proto.Telemetry + +/** Chart series colours for the four host metrics. */ +private enum class HostMetric(val color: Color) { + LOAD_1(GraphColors.Blue), + LOAD_5(GraphColors.Green), + LOAD_15(GraphColors.Orange), + FREE_MEM(GraphColors.Teal), +} + +/** Legend entries for the host metrics chart. */ +internal val HOST_METRICS_LEGEND_DATA = + listOf( + LegendData(nameRes = Res.string.load_1_min, color = HostMetric.LOAD_1.color, isLine = true), + LegendData(nameRes = Res.string.load_5_min, color = HostMetric.LOAD_5.color, isLine = true), + LegendData(nameRes = Res.string.load_15_min, color = HostMetric.LOAD_15.color, isLine = true), + LegendData(nameRes = Res.string.free_memory, color = HostMetric.FREE_MEM.color, isLine = true), + ) + +/** Info-dialog entries describing each host metric for the legend help overlay. */ +internal val HOST_METRICS_INFO_DATA = + listOf( + InfoDialogData( + titleRes = Res.string.load_1_min, + definitionRes = Res.string.load_1_min_description, + color = HostMetric.LOAD_1.color, + ), + InfoDialogData( + titleRes = Res.string.load_5_min, + definitionRes = Res.string.load_5_min_description, + color = HostMetric.LOAD_5.color, + ), + InfoDialogData( + titleRes = Res.string.load_15_min, + definitionRes = Res.string.load_15_min_description, + color = HostMetric.LOAD_15.color, + ), + InfoDialogData( + titleRes = Res.string.free_memory, + definitionRes = Res.string.free_memory_description, + color = HostMetric.FREE_MEM.color, + ), + ) + +/** + * Vico chart composable that renders load averages (1m, 5m, 15m) and free memory as dual-axis line series: load on the + * start axis (fixed min 0), free memory in MB on the end axis. + * + * Load values from the proto are in 1/100ths (e.g. 150 = 1.50 load). They are divided by 100 for display. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun HostMetricsChart( + modifier: Modifier = Modifier, + data: List, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, +) { + Column(modifier = modifier) { + if (data.isEmpty()) return@Column + + val modelProducer = remember { CartesianChartModelProducer() } + + val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } } + val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } } + val load15Data = + remember(data) { data.filter { it.host_metrics?.load15 != null && it.host_metrics!!.load15 > 0 } } + val memData = + remember(data) { + data.filter { it.host_metrics?.freemem_bytes != null && it.host_metrics!!.freemem_bytes > 0 } + } + + LaunchedEffect(load1Data, load5Data, load15Data, memData) { + modelProducer.runTransaction { + val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty() + if (hasLoad) { + lineSeries { + if (load1Data.isNotEmpty()) { + series(x = load1Data.map { it.time }, y = load1Data.map { it.host_metrics!!.load1 / 100.0 }) + } + if (load5Data.isNotEmpty()) { + series(x = load5Data.map { it.time }, y = load5Data.map { it.host_metrics!!.load5 / 100.0 }) + } + if (load15Data.isNotEmpty()) { + series( + x = load15Data.map { it.time }, + y = load15Data.map { it.host_metrics!!.load15 / 100.0 }, + ) + } + } + } + if (memData.isNotEmpty()) { + lineSeries { + series( + x = memData.map { it.time }, + y = memData.map { it.host_metrics!!.freemem_bytes.toDouble() / BYTES_IN_MB }, + ) + } + } + } + } + + val load1Color = HostMetric.LOAD_1.color + val load5Color = HostMetric.LOAD_5.color + val load15Color = HostMetric.LOAD_15.color + val memColor = HostMetric.FREE_MEM.color + + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color.copy(alpha = 1f)) { + load1Color -> formatString("L1: %.2f", value) + load5Color -> formatString("L5: %.2f", value) + load15Color -> formatString("L15: %.2f", value) + else -> formatString("Mem: %.0f MB", value) + } + }, + ) + + val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty() + + val loadLayer = + if (hasLoad) { + val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null + val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null + val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null + val styles = listOfNotNull(load1Style, load5Style, load15Style) + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(styles), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + } else { + null + } + + val memLayer = + if (memData.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + } else { + null + } + + val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) } + + if (layers.isNotEmpty()) { + GenericMetricChart( + modelProducer = modelProducer, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + layers = layers, + startAxis = + if (hasLoad) { + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = load1Color), + valueFormatter = { _, value, _ -> formatString("%.1f", value) }, + ) + } else { + null + }, + endAxis = + if (memData.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = memColor), + valueFormatter = { _, value, _ -> formatString("%.0f MB", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) + } + + Legend(legendData = HOST_METRICS_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 2d0a9584e..f22710ef5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -14,201 +14,210 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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.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.text.selection.SelectionContainer +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disk_free_indexed import org.meshtastic.core.resources.free_memory +import org.meshtastic.core.resources.host_metrics_log import org.meshtastic.core.resources.load_indexed import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.user_string -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.icon.DataArray -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.theme.GraphColors import org.meshtastic.proto.Telemetry +/** + * Full-screen host metrics log with chart and card list, built on [BaseMetricScreen]. Shows load averages and free + * memory over time with time-frame filtering, chart expand/collapse, and card-to-chart synchronisation. + */ @OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod") @Composable -fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) { - val state by metricsViewModel.state.collectAsStateWithLifecycle() +fun HostMetricsLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() - val hostMetrics = state.hostMetrics + val threshold = timeFrame.timeThreshold() + val filteredData = + remember(state.hostMetrics, threshold) { state.hostMetrics.filter { it.time.toLong() >= threshold } } - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.HOST) }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.HOST, + titleRes = Res.string.host_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = filteredData, + timeProvider = { it.time.toDouble() }, + infoData = HOST_METRICS_INFO_DATA, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.HOST) }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - ) { innerPadding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp), + chartPart = { chartModifier, selectedX, vicoScrollState, onPointSelected -> + HostMetricsChart( + modifier = chartModifier, + data = filteredData.reversed(), + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, + ) + }, + listPart = { listModifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = listModifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(filteredData, key = { index, t -> "${t.time}_$index" }) { _, telemetry -> + HostMetricsCard( + telemetry = telemetry, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { onCardClick(telemetry.time.toDouble()) }, + ) + } + } + }, + ) +} + +/** A selectable card summarising a single host metrics telemetry snapshot. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HostMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { + val hostMetrics = telemetry.host_metrics + val time = DateFormatter.formatDateTime(telemetry.time.toLong() * MS_PER_SEC) + var expanded by remember { mutableStateOf(false) } + + Box { + Card( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .combinedClickable(onClick = onClick, onLongClick = { expanded = true }), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { - items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) } + HostMetricsCardContent(time = time, hostMetrics = hostMetrics) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DeleteItem { expanded = false } } + } +} + +/** Card body showing timestamp, load averages with progress bars, memory, disk, and uptime. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = time, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + + hostMetrics?.uptime_seconds?.let { + LogLine(label = stringResource(Res.string.uptime), value = formatUptime(it)) + } + hostMetrics?.freemem_bytes?.let { + LogLine(label = stringResource(Res.string.free_memory), value = formatBytes(it)) + } + + // Disk free rows + hostMetrics?.diskfree1_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 1), value = formatBytes(it)) + } + hostMetrics?.diskfree2_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 2), value = formatBytes(it)) + } + hostMetrics?.diskfree3_bytes?.let { + LogLine(label = stringResource(Res.string.disk_free_indexed, 3), value = formatBytes(it)) + } + + // Load averages with coloured indicators and progress bars + hostMetrics?.load1?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 1), value = it, color = GraphColors.Blue) + } + hostMetrics?.load5?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 5), value = it, color = GraphColors.Green) + } + hostMetrics?.load15?.let { + LoadRow(label = stringResource(Res.string.load_indexed, 15), value = it, color = GraphColors.Orange) + } + + hostMetrics?.user_string?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium) + Text(text = it, style = MaterialTheme.typography.bodySmall) } } } -@Suppress("LongMethod", "MagicNumber") +/** A load average row with coloured metric indicator, value text, and progress bar. */ @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) { - val hostMetrics = telemetry.host_metrics - val time = telemetry.time.toLong() * TimeConstants.MS_PER_SEC - Card( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Row(modifier = Modifier.padding(16.dp)) { - Icon(imageVector = MeshtasticIcons.DataArray, contentDescription = null, modifier = Modifier.width(24.dp)) - Spacer(modifier = Modifier.width(16.dp)) - SelectionContainer { - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - text = DateFormatter.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - hostMetrics?.uptime_seconds?.let { - LogLine( - label = stringResource(Res.string.uptime), - value = formatUptime(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.freemem_bytes?.let { - LogLine( - label = stringResource(Res.string.free_memory), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree1_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 1), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree2_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 2), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.diskfree3_bytes?.let { - LogLine( - label = stringResource(Res.string.disk_free_indexed, 3), - value = formatBytes(it), - modifier = Modifier.fillMaxWidth(), - ) - } - hostMetrics?.load1?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 1), - value = (hostMetrics.load1 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load1 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.load5?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 5), - value = (hostMetrics.load5 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load5 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.load15?.let { - LogLine( - label = stringResource(Res.string.load_indexed, 15), - value = (hostMetrics.load15 / 100.0).toString(), - modifier = Modifier.fillMaxWidth(), - ) - LinearProgressIndicator( - progress = { hostMetrics.load15 / 10000.0f }, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } - hostMetrics?.user_string?.let { - Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium) - Text(text = it, style = TextStyle(fontFamily = FontFamily.Monospace)) - } - } - } - } +private fun LoadRow(label: String, value: Int, color: androidx.compose.ui.graphics.Color) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(color) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = formatString("%s: %.2f", label, value / 100.0), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + ) } + LinearProgressIndicator( + progress = { (value / 10000.0f).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + color = color, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) } @Composable diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index c2dc2058d..ed445947c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -45,9 +45,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -137,29 +137,15 @@ private fun PaxMetricsChart( 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, - ), + ChartStyling.createGradientLine(lineColor = bleColor), + ChartStyling.createGradientLine(lineColor = wifiColor), + ChartStyling.createBoldLine(lineColor = paxColor), ), + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), ), ), startAxis = VerticalAxis.rememberStart(label = axisLabel), - bottomAxis = - HorizontalAxis.rememberBottom( - label = axisLabel, - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 5501554bf..234ba269a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -20,6 +20,7 @@ package org.meshtastic.feature.node.metrics import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -54,7 +56,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -68,6 +69,11 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 import org.meshtastic.core.resources.channel_2 import org.meshtastic.core.resources.channel_3 +import org.meshtastic.core.resources.channel_4 +import org.meshtastic.core.resources.channel_5 +import org.meshtastic.core.resources.channel_6 +import org.meshtastic.core.resources.channel_7 +import org.meshtastic.core.resources.channel_8 import org.meshtastic.core.resources.current import org.meshtastic.core.resources.power_metrics_log import org.meshtastic.core.resources.voltage @@ -85,6 +91,11 @@ private enum class PowerChannel(val strRes: StringResource) { ONE(Res.string.channel_1), TWO(Res.string.channel_2), THREE(Res.string.channel_3), + FOUR(Res.string.channel_4), + FIVE(Res.string.channel_5), + SIX(Res.string.channel_6), + SEVEN(Res.string.channel_7), + EIGHT(Res.string.channel_8), } private val LEGEND_DATA = @@ -110,6 +121,12 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } + val availableChannels = + remember(data) { + PowerChannel.entries.filter { channel -> + data.any { !retrieveVoltage(channel, it).isNaN() || !retrieveCurrent(channel, it).isNaN() } + } + } var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } BaseMetricScreen( @@ -130,10 +147,11 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { ) Spacer(modifier = Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - PowerChannel.entries.forEach { channel -> + availableChannels.forEach { channel -> FilterChip( selected = selectedChannel == channel, onClick = { selectedChannel = channel }, @@ -229,10 +247,7 @@ private fun PowerMetricsChart( val currentLayer = if (currentData.isNotEmpty()) { rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)), verticalAxisPosition = Axis.Position.Vertical.Start, ) } else { @@ -243,9 +258,7 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { rememberLineCartesianLayer( lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP), - ), + LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)), verticalAxisPosition = Axis.Position.Vertical.End, ) } else { @@ -277,13 +290,7 @@ private fun PowerMetricsChart( } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, @@ -296,7 +303,7 @@ private fun PowerMetricsChart( } @Composable -@Suppress("CyclomaticComplexMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod") @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val time = telemetry.time.toLong() * MS_PER_SEC @@ -328,19 +335,10 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - val pm = telemetry.power_metrics - if (pm != null) { - if (pm.ch1_current != null || pm.ch1_voltage != null) { - PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f) - } - if (pm.ch2_current != null || pm.ch2_voltage != null) { - PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f) - } - if (pm.ch3_current != null || pm.ch3_voltage != null) { - PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f) - } - } + val pm = telemetry.power_metrics + if (pm != null) { + PowerChannelsRow1(pm) + PowerChannelsExtraRows(pm) } } } @@ -349,6 +347,61 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: } } +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (pm.ch1_current != null || pm.ch1_voltage != null) { + PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f) + } + if (pm.ch2_current != null || pm.ch2_voltage != null) { + PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f) + } + if (pm.ch3_current != null || pm.ch3_voltage != null) { + PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f) + } + } +} + +@Composable +@Suppress("CyclomaticComplexMethod") +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) { + val hasCh456 = + hasChannelData(pm.ch4_voltage, pm.ch4_current) || + hasChannelData(pm.ch5_voltage, pm.ch5_current) || + hasChannelData(pm.ch6_voltage, pm.ch6_current) + val hasCh78 = hasChannelData(pm.ch7_voltage, pm.ch7_current) || hasChannelData(pm.ch8_voltage, pm.ch8_current) + + if (hasCh456) { + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (hasChannelData(pm.ch4_voltage, pm.ch4_current)) { + PowerChannelColumn(Res.string.channel_4, pm.ch4_voltage ?: 0f, pm.ch4_current ?: 0f) + } + if (hasChannelData(pm.ch5_voltage, pm.ch5_current)) { + PowerChannelColumn(Res.string.channel_5, pm.ch5_voltage ?: 0f, pm.ch5_current ?: 0f) + } + if (hasChannelData(pm.ch6_voltage, pm.ch6_current)) { + PowerChannelColumn(Res.string.channel_6, pm.ch6_voltage ?: 0f, pm.ch6_current ?: 0f) + } + } + } + if (hasCh78) { + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + if (hasChannelData(pm.ch7_voltage, pm.ch7_current)) { + PowerChannelColumn(Res.string.channel_7, pm.ch7_voltage ?: 0f, pm.ch7_current ?: 0f) + } + if (hasChannelData(pm.ch8_voltage, pm.ch8_current)) { + PowerChannelColumn(Res.string.channel_8, pm.ch8_voltage ?: 0f, pm.ch8_current ?: 0f) + } + } + } +} + +private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null + @Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) { @@ -380,17 +433,29 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current } /** Retrieves the appropriate voltage depending on `channelSelected`. */ +@Suppress("CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN PowerChannel.THREE -> telemetry.power_metrics?.ch3_voltage ?: Float.NaN + PowerChannel.FOUR -> telemetry.power_metrics?.ch4_voltage ?: Float.NaN + PowerChannel.FIVE -> telemetry.power_metrics?.ch5_voltage ?: Float.NaN + PowerChannel.SIX -> telemetry.power_metrics?.ch6_voltage ?: Float.NaN + PowerChannel.SEVEN -> telemetry.power_metrics?.ch7_voltage ?: Float.NaN + PowerChannel.EIGHT -> telemetry.power_metrics?.ch8_voltage ?: Float.NaN } /** Retrieves the appropriate current depending on `channelSelected`. */ +@Suppress("CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN PowerChannel.THREE -> telemetry.power_metrics?.ch3_current ?: Float.NaN + PowerChannel.FOUR -> telemetry.power_metrics?.ch4_current ?: Float.NaN + PowerChannel.FIVE -> telemetry.power_metrics?.ch5_current ?: Float.NaN + PowerChannel.SIX -> telemetry.power_metrics?.ch6_current ?: Float.NaN + PowerChannel.SEVEN -> telemetry.power_metrics?.ch7_current ?: Float.NaN + PowerChannel.EIGHT -> telemetry.power_metrics?.ch8_current ?: Float.NaN } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index f9c3d6955..4105eb749 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -180,10 +179,7 @@ private fun SignalMetricsChart( val rssiLayer = if (rssiData.isNotEmpty()) { rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP), - ), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)), verticalAxisPosition = Axis.Position.Vertical.Start, ) } else { @@ -193,10 +189,7 @@ private fun SignalMetricsChart( val snrLayer = if (snrData.isNotEmpty()) { rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP), - ), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)), verticalAxisPosition = Axis.Position.Vertical.End, ) } else { @@ -228,13 +221,7 @@ private fun SignalMetricsChart( } else { null }, - bottomAxis = - HorizontalAxis.rememberBottom( - label = ChartStyling.rememberAxisLabel(), - valueFormatter = CommonCharts.dynamicTimeFormatter, - itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), - labelRotationDegrees = 45f, - ), + bottomAxis = CommonCharts.rememberBottomTimeAxis(), marker = marker, selectedX = selectedX, onPointSelected = onPointSelected, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt new file mode 100644 index 000000000..76ac08502 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -0,0 +1,263 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber", "MatchingDeclarationName") + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider +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.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.traceroute_duration +import org.meshtastic.core.resources.traceroute_forward_hops +import org.meshtastic.core.resources.traceroute_outgoing_route +import org.meshtastic.core.resources.traceroute_return_hops +import org.meshtastic.core.resources.traceroute_return_route +import org.meshtastic.core.resources.traceroute_round_trip +import org.meshtastic.core.ui.theme.GraphColors + +/** Resolved traceroute data point pairing a request with its optional response. */ +internal data class TraceroutePoint( + val request: MeshLog, + val result: MeshLog?, + /** Request timestamp in epoch seconds, used as the chart X coordinate. */ + val timeSeconds: Double, + /** Number of intermediate hops toward the destination, or null if no response received. */ + val forwardHops: Int?, + /** Number of intermediate hops on the return path, or null if unavailable. */ + val returnHops: Int?, + /** Round-trip duration in seconds between request sent and response received, or null. */ + val roundTripSeconds: Double?, +) + +/** Chart series colours for the three traceroute metrics. */ +private enum class TracerouteMetric(val color: Color) { + FORWARD_HOPS(GraphColors.Blue), + RETURN_HOPS(GraphColors.Green), + ROUND_TRIP(GraphColors.Orange), +} + +/** Legend entries for the traceroute chart — forward hops, return hops, and round-trip duration. */ +internal val TRACEROUTE_LEGEND_DATA = + listOf( + LegendData( + nameRes = Res.string.traceroute_forward_hops, + color = TracerouteMetric.FORWARD_HOPS.color, + isLine = true, + ), + LegendData( + nameRes = Res.string.traceroute_return_hops, + color = TracerouteMetric.RETURN_HOPS.color, + isLine = true, + ), + LegendData( + nameRes = Res.string.traceroute_round_trip, + color = TracerouteMetric.ROUND_TRIP.color, + isLine = true, + ), + ) + +/** Info-dialog entries describing each traceroute metric for the legend help overlay. */ +internal val TRACEROUTE_INFO_DATA = + listOf( + InfoDialogData( + titleRes = Res.string.traceroute_forward_hops, + definitionRes = Res.string.traceroute_outgoing_route, + color = TracerouteMetric.FORWARD_HOPS.color, + ), + InfoDialogData( + titleRes = Res.string.traceroute_return_hops, + definitionRes = Res.string.traceroute_return_route, + color = TracerouteMetric.RETURN_HOPS.color, + ), + InfoDialogData( + titleRes = Res.string.traceroute_round_trip, + definitionRes = Res.string.traceroute_duration, + color = TracerouteMetric.ROUND_TRIP.color, + ), + ) + +/** + * Matches each traceroute request with its response (if any) and computes hop counts and round-trip duration. Results + * are ordered the same as [requests] — newest-first when coming from the ViewModel. + */ +internal fun resolveTraceroutePoints(requests: List, results: List): List = + requests.map { request -> + val requestPacketId = request.fromRadio.packet?.id + val result = results.find { it.fromRadio.packet?.decoded?.request_id == requestPacketId } + val route = result?.fromRadio?.packet?.fullRouteDiscovery + val timeSeconds = request.received_date.toDouble() / MS_PER_SEC + + val forwardHops = route?.let { maxOf(0, it.route.size - 2) } + val returnHops = route?.let { if (it.route_back.isNotEmpty()) maxOf(0, it.route_back.size - 2) else null } + val roundTrip = + if (result != null) { + (result.received_date - request.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC + } else { + null + } + + TraceroutePoint( + request = request, + result = result, + timeSeconds = timeSeconds, + forwardHops = forwardHops, + returnHops = returnHops, + roundTripSeconds = roundTrip, + ) + } + +/** + * Vico chart composable that renders forward hops, return hops, and round-trip duration as separate line series with + * dual Y-axes: hops on the start axis (fixed min 0) and RTT seconds on the end axis. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun TracerouteMetricsChart( + modifier: Modifier = Modifier, + points: List, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, +) { + Column(modifier = modifier) { + if (points.isEmpty()) return@Column + + val modelProducer = remember { CartesianChartModelProducer() } + + val forwardData = remember(points) { points.filter { it.forwardHops != null } } + val returnData = remember(points) { points.filter { it.returnHops != null } } + val rttData = remember(points) { points.filter { it.roundTripSeconds != null } } + + LaunchedEffect(forwardData, returnData, rttData) { + modelProducer.runTransaction { + if (forwardData.isNotEmpty()) { + lineSeries { + series(x = forwardData.map { it.timeSeconds }, y = forwardData.map { it.forwardHops!! }) + } + } + if (returnData.isNotEmpty()) { + lineSeries { series(x = returnData.map { it.timeSeconds }, y = returnData.map { it.returnHops!! }) } + } + if (rttData.isNotEmpty()) { + lineSeries { series(x = rttData.map { it.timeSeconds }, y = rttData.map { it.roundTripSeconds!! }) } + } + } + } + + val forwardColor = TracerouteMetric.FORWARD_HOPS.color + val returnColor = TracerouteMetric.RETURN_HOPS.color + val rttColor = TracerouteMetric.ROUND_TRIP.color + + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color.copy(alpha = 1f)) { + forwardColor -> formatString("Fwd: %.0f hops", value) + returnColor -> formatString("Ret: %.0f hops", value) + else -> formatString("RTT: %.1f s", value) + } + }, + ) + + val forwardLayer = + if (forwardData.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + } else { + null + } + + val returnLayer = + if (returnData.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) + } else { + null + } + + val rttLayer = + if (rttData.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) + } else { + null + } + + val layers = + remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) } + + if (layers.isNotEmpty()) { + GenericMetricChart( + modelProducer = modelProducer, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + layers = layers, + startAxis = + if (forwardData.isNotEmpty() || returnData.isNotEmpty()) { + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = forwardColor), + valueFormatter = { _, value, _ -> formatString("%.0f", value) }, + ) + } else { + null + }, + endAxis = + if (rttData.isNotEmpty()) { + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = rttColor), + valueFormatter = { _, value, _ -> formatString("%.1f s", value) }, + ) + } else { + null + }, + bottomAxis = CommonCharts.rememberBottomTimeAxis(), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) + } + + Legend(legendData = TRACEROUTE_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 4d00c684a..6fa914b2a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -14,29 +14,44 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +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.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.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.pluralStringResource @@ -47,22 +62,23 @@ import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.routing_error_no_response -import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.traceroute_diff import org.meshtastic.core.resources.traceroute_direct import org.meshtastic.core.resources.traceroute_duration +import org.meshtastic.core.resources.traceroute_forward_hops import org.meshtastic.core.resources.traceroute_hops import org.meshtastic.core.resources.traceroute_log +import org.meshtastic.core.resources.traceroute_no_response +import org.meshtastic.core.resources.traceroute_return_hops +import org.meshtastic.core.resources.traceroute_round_trip import org.meshtastic.core.resources.traceroute_route_back_to_us import org.meshtastic.core.resources.traceroute_route_towards_dest -import org.meshtastic.core.resources.traceroute_time_and_text -import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.Group 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.icon.Route +import org.meshtastic.core.ui.theme.GraphColors import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @@ -71,8 +87,13 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.proto.RouteDiscovery +/** + * Full-screen traceroute log with chart and card list, built on [BaseMetricScreen]. Shows forward/return hops and + * round-trip duration over time. Supports time-frame filtering, chart expand/collapse, and card-to-chart + * synchronisation. + */ @OptIn(ExperimentalFoundationApi::class) -@Suppress("LongMethod", "CyclomaticComplexMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod", "UnusedParameter") @Composable fun TracerouteLogScreen( modifier: Modifier = Modifier, @@ -81,6 +102,9 @@ fun TracerouteLogScreen( onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> }, ) { val state by viewModel.state.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle() fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" } @@ -88,155 +112,275 @@ fun TracerouteLogScreen( val statusYellow = MaterialTheme.colorScheme.StatusYellow val statusOrange = MaterialTheme.colorScheme.StatusOrange - Scaffold( - topBar = { - val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle() - MainAppBar( - title = state.node?.user?.long_name ?: "", - subtitle = stringResource(Res.string.traceroute_log), - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - CooldownIconButton( - onClick = { viewModel.requestTraceroute() }, - cooldownTimestamp = lastTracerouteTime, - ) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, + val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest) + val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us) + val durationTemplate = stringResource(Res.string.traceroute_duration, "%SECS%") + + val threshold = timeFrame.timeThreshold() + val filteredRequests = + remember(state.tracerouteRequests, threshold) { + state.tracerouteRequests.filter { (it.received_date / MS_PER_SEC) >= threshold } + } + + val points = + remember(filteredRequests, state.tracerouteResults) { + resolveTraceroutePoints(filteredRequests, state.tracerouteResults) + } + + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = null, + titleRes = Res.string.traceroute_log, + nodeName = state.node?.user?.long_name ?: "", + data = points, + timeProvider = { it.timeSeconds }, + infoData = TRACEROUTE_INFO_DATA, + extraActions = { + if (!state.isLocal) { + CooldownIconButton( + onClick = { viewModel.requestTraceroute() }, + cooldownTimestamp = lastTracerouteTime, + ) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } + }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - ) { innerPadding -> - LazyColumn( - modifier = modifier.fillMaxSize().padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - items(state.tracerouteRequests, key = { it.uuid }) { log -> - val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest) - val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us) - val result = - remember(state.tracerouteRequests, log.fromRadio.packet?.id) { - state.tracerouteResults.find { - it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id - } - } - val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery } - - val time = DateFormatter.formatDateTime(log.received_date) - val (text, icon) = route.getTextAndIcon() - var expanded by remember { mutableStateOf(false) } - - val tracerouteDetailsAnnotated: AnnotatedString? = result?.let { res -> - if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) { - val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC - val annotatedBase = - annotateTraceroute( - res.fromRadio.packet?.getTracerouteResponse( - ::getUsername, - headerTowards = stringResource(Res.string.traceroute_route_towards_dest), - headerBack = headerBackStr, - ), + chartPart = { chartModifier, selectedX, vicoScrollState, onPointSelected -> + TracerouteMetricsChart( + modifier = chartModifier, + points = points.reversed(), + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, + ) + }, + listPart = { listModifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = listModifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(points, key = { _, point -> point.request.uuid }) { _, point -> + TracerouteCard( + point = point, + isSelected = point.timeSeconds == selectedX, + onClick = { onCardClick(point.timeSeconds) }, + onLongClick = { viewModel.deleteLog(point.request.uuid) }, + onShowDetail = { + showTracerouteDetail( + point = point, + viewModel = viewModel, + getUsername = ::getUsername, + headerTowards = headerTowardsStr, + headerBack = headerBackStr, + durationTemplate = durationTemplate, statusGreen = statusGreen, statusYellow = statusYellow, statusOrange = statusOrange, + onViewOnMap = onViewOnMap, ) - val durationText = stringResource(Res.string.traceroute_duration, formatString("%.1f", seconds)) - buildAnnotatedString { - append(annotatedBase) - append("\n\n$durationText") - } - } else { - // For cases where there's a result but no full route, display plain text - res.fromRadio.packet - ?.getTracerouteResponse( - ::getUsername, - headerTowards = stringResource(Res.string.traceroute_route_towards_dest), - headerBack = headerBackStr, - ) - ?.let { AnnotatedString(it) } - } - } - val overlay = route?.let { - TracerouteOverlay( - requestId = log.fromRadio.packet?.id ?: 0, - forwardRoute = it.route, - returnRoute = it.route_back, - ) - } - - Box { - 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 = - tracerouteDetailsAnnotated - ?: result - ?.fromRadio - ?.packet - ?.getTracerouteResponse( - ::getUsername, - headerTowards = headerTowardsStr, - headerBack = headerBackStr, - ) - ?.let { - annotateTraceroute( - it, - statusGreen = statusGreen, - statusYellow = statusYellow, - statusOrange = statusOrange, - ) - } - dialogMessage?.let { - val responseLogUuid = result?.uuid ?: return@combinedClickable - viewModel.showTracerouteDetail( - annotatedMessage = it, - requestId = log.fromRadio.packet?.id ?: 0, - responseLogUuid = responseLogUuid, - overlay = overlay, - onViewOnMap = onViewOnMap, - onShowError = { /* Handle error */ }, - ) - } }, ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DeleteItem { - viewModel.deleteLog(log.uuid) - expanded = false - } - } } } + }, + ) +} + +/** A selectable card summarising a single traceroute request/response pair. */ +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun TracerouteCard( + point: TraceroutePoint, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onShowDetail: () -> Unit, +) { + val route = point.result?.fromRadio?.packet?.fullRouteDiscovery + val time = DateFormatter.formatDateTime(point.request.received_date) + val (summaryText, icon) = route.getTextAndIcon() + var expanded by remember { mutableStateOf(false) } + + Box { + Card( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .combinedClickable( + onLongClick = { expanded = true }, + onClick = { + onClick() + onShowDetail() + }, + ), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + TracerouteCardContent(time = time, summaryText = summaryText, icon = icon, point = point) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DeleteItem { + onLongClick() + expanded = false + } } } } +/** Card body showing timestamp, route summary text/icon, and metric indicators. */ +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private fun TracerouteCardContent(time: String, summaryText: String, icon: ImageVector, point: TraceroutePoint) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = time, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold) + Icon(imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = summaryText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TracerouteCardMetrics(point) + } +} + +/** Compact coloured metric indicators (forward hops / return hops / RTT) shown at the bottom of a card. */ +@Composable +private fun TracerouteCardMetrics(point: TraceroutePoint) { + if (point.forwardHops == null && point.returnHops == null && point.roundTripSeconds == null) return + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + point.forwardHops?.let { hops -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Blue) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %d", stringResource(Res.string.traceroute_forward_hops), hops), + style = MaterialTheme.typography.labelLarge, + ) + } + } + point.returnHops?.let { hops -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Green) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %d", stringResource(Res.string.traceroute_return_hops), hops), + style = MaterialTheme.typography.labelLarge, + ) + } + } + point.roundTripSeconds?.let { rtt -> + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(GraphColors.Orange) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString("%s: %.1f s", stringResource(Res.string.traceroute_round_trip), rtt), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } +} + +/** Builds annotated route text and opens the traceroute detail dialog via the ViewModel. */ +@Suppress("LongParameterList") +private fun showTracerouteDetail( + point: TraceroutePoint, + viewModel: MetricsViewModel, + getUsername: (Int) -> String, + headerTowards: String, + headerBack: String, + durationTemplate: String, + statusGreen: Color, + statusYellow: Color, + statusOrange: Color, + onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit, +) { + val result = point.result ?: return + val route = result.fromRadio.packet?.fullRouteDiscovery + + val annotated: AnnotatedString = + if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) { + val seconds = point.roundTripSeconds ?: 0.0 + val annotatedBase = + annotateTraceroute( + result.fromRadio.packet?.getTracerouteResponse( + getUsername, + headerTowards = headerTowards, + headerBack = headerBack, + ), + statusGreen = statusGreen, + statusYellow = statusYellow, + statusOrange = statusOrange, + ) + val durationText = durationTemplate.replace("%SECS%", formatString("%.1f", seconds)) + buildAnnotatedString { + append(annotatedBase) + append("\n\n$durationText") + } + } else { + result.fromRadio.packet + ?.getTracerouteResponse(getUsername, headerTowards = headerTowards, headerBack = headerBack) + ?.let { AnnotatedString(it) } ?: return + } + + val overlay = + route?.let { + TracerouteOverlay( + requestId = point.request.fromRadio.packet?.id ?: 0, + forwardRoute = it.route, + returnRoute = it.route_back, + ) + } + + viewModel.showTracerouteDetail( + annotatedMessage = annotated, + requestId = point.request.fromRadio.packet?.id ?: 0, + responseLogUuid = result.uuid, + overlay = overlay, + onViewOnMap = onViewOnMap, + onShowError = {}, + ) +} + /** Generates a display string and icon based on the route discovery information. */ @Composable private fun RouteDiscovery?.getTextAndIcon(): Pair = when { this == null -> { - stringResource(Res.string.routing_error_no_response) to MeshtasticIcons.PersonOff + stringResource(Res.string.traceroute_no_response) to MeshtasticIcons.PersonOff } - // A direct route means the sender and receiver are the only two nodes in the route. - route.size <= 2 && route_back.size <= 2 -> { // also check route_back size for direct to be more robust + route.size <= 2 && route_back.size <= 2 -> { stringResource(Res.string.traceroute_direct) to MeshtasticIcons.Group } - route.size == route_back.size -> { val hops = route.size - 2 pluralStringResource(Res.plurals.traceroute_hops, hops, hops) to MeshtasticIcons.Route } - else -> { - // Asymmetric route val towards = maxOf(0, route.size - 2) val back = maxOf(0, route_back.size - 2) stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 276e2892e..8f2dacf25 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -245,7 +245,7 @@ enum class NodeDetailRoute( Res.string.host, NodeDetailRoutes.HostMetricsLog::class, Icons.Rounded.Memory, - { metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) }, + { metricsVM, onNavigateUp -> HostMetricsLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) }, ), PAX( Res.string.pax, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt new file mode 100644 index 000000000..aaa0d8631 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.feature.node.metrics + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Tests for [formatBytes] — the pure function that formats byte counts into human-readable strings. */ +@Suppress("MagicNumber") +class FormatBytesTest { + + @Test + fun zero_bytes() { + assertEquals("0 B", formatBytes(0L)) + } + + @Test + fun small_byte_values() { + assertEquals("1 B", formatBytes(1L)) + assertEquals("512 B", formatBytes(512L)) + assertEquals("1023 B", formatBytes(1023L)) + } + + @Test + fun kilobyte_boundary() { + assertEquals("1 KB", formatBytes(1024L)) + } + + @Test + fun kilobyte_with_decimals() { + // 1536 bytes = 1.5 KB + assertEquals("1.5 KB", formatBytes(1536L)) + } + + @Test + fun megabyte_boundary() { + assertEquals("1 MB", formatBytes(1024L * 1024)) + } + + @Test + fun megabyte_with_decimals() { + // 1.5 MB = 1572864 bytes + assertEquals("1.5 MB", formatBytes(1_572_864L)) + } + + @Test + fun gigabyte_boundary() { + assertEquals("1 GB", formatBytes(1024L * 1024 * 1024)) + } + + @Test + fun gigabyte_with_decimals() { + // 2.5 GB + assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong())) + } + + @Test + fun negative_bytes_returns_na() { + assertEquals("N/A", formatBytes(-1L)) + assertEquals("N/A", formatBytes(-1024L)) + } + + @Test + fun large_values() { + // 100 GB + assertEquals("100 GB", formatBytes(100L * 1024 * 1024 * 1024)) + } + + @Test + fun custom_decimal_places_zero() { + // 1536 bytes = 1.5 KB, with 0 decimal places → 2 KB (rounded) + assertEquals("2 KB", formatBytes(1536L, decimalPlaces = 0)) + } + + @Test + fun custom_decimal_places_one() { + // 1536 bytes = 1.5 KB, with 1 decimal place → 1.5 KB + assertEquals("1.5 KB", formatBytes(1536L, decimalPlaces = 1)) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt new file mode 100644 index 000000000..060925fb3 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 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 . + */ +package org.meshtastic.feature.node.metrics + +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.RouteDiscovery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Tests for [resolveTraceroutePoints] — the pure function that pairs traceroute requests with their responses and + * computes hop counts and round-trip duration. + * + * Wire format note: The [RouteDiscovery] proto on the wire contains only **intermediate** hops (not endpoints). + * [MeshPacket.fullRouteDiscovery] prepends the destination and appends the source to produce the full route. For + * `route_back` to be wrapped with endpoints, `hop_start > 0` and `snr_back` must be non-empty. + */ +@Suppress("MagicNumber") +class TracerouteChartTest { + + companion object { + /** Node number for the local (requesting) node. */ + private const val LOCAL_NODE = 1 + + /** Node number for the remote (destination) node. */ + private const val REMOTE_NODE = 2 + + /** Dummy SNR value used to satisfy the snr_back requirement. */ + private const val DUMMY_SNR = 10 + } + + /** + * Creates a traceroute **request** MeshLog. + * + * @param id Packet ID used to correlate request with response. + * @param receivedDateMillis Timestamp in milliseconds. + */ + private fun makeRequest(id: Int, receivedDateMillis: Long): MeshLog = MeshLog( + uuid = "req-$id", + message_type = "TRACEROUTE", + received_date = receivedDateMillis, + raw_message = "", + fromRadio = + FromRadio( + packet = + MeshPacket( + id = id, + from = LOCAL_NODE, + to = REMOTE_NODE, + decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true), + ), + ), + ) + + /** + * Creates a traceroute **result** MeshLog that matches a request by [requestId]. + * + * @param intermediateRoute Intermediate hops on the forward path (wire format, no endpoints). + * @param intermediateRouteBack Intermediate hops on the return path (wire format, no endpoints). Pass `null` to + * omit route_back entirely (simulates no return route data). + * @param hopStart Non-zero hop_start is required (along with snr_back) for fullRouteDiscovery to wrap route_back + * with endpoints. Defaults to 3. + */ + private fun makeResult( + requestId: Int, + receivedDateMillis: Long, + intermediateRoute: List = listOf(3), + intermediateRouteBack: List? = listOf(3), + hopStart: Int = 3, + ): MeshLog { + // snr_back must have one entry per node in route_back for fullRouteDiscovery to wrap it + val snrBack = intermediateRouteBack?.map { DUMMY_SNR } ?: emptyList() + val rd = + RouteDiscovery( + route = intermediateRoute, + route_back = intermediateRouteBack ?: emptyList(), + snr_back = snrBack, + ) + return MeshLog( + uuid = "res-$requestId", + message_type = "TRACEROUTE", + received_date = receivedDateMillis, + raw_message = "", + fromRadio = + FromRadio( + packet = + MeshPacket( + from = REMOTE_NODE, + to = LOCAL_NODE, + hop_start = hopStart, + decoded = + Data( + portnum = PortNum.TRACEROUTE_APP, + request_id = requestId, + payload = RouteDiscovery.ADAPTER.encode(rd).toByteString(), + ), + ), + ), + ) + } + + @Test + fun matchesRequestToResult() { + val requestTime = 1000L * MS_PER_SEC + val resultTime = 1005L * MS_PER_SEC + val requests = listOf(makeRequest(id = 42, receivedDateMillis = requestTime)) + val results = listOf(makeResult(requestId = 42, receivedDateMillis = resultTime)) + + val points = resolveTraceroutePoints(requests, results) + + assertEquals(1, points.size) + val point = points.first() + assertEquals(requests.first(), point.request) + assertNotNull(point.result) + // timeSeconds = received_date (millis) / MS_PER_SEC + assertEquals(1000.0, point.timeSeconds) + } + + @Test + fun computesForwardHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // 2 intermediate hops → fullRoute = [dest, hop1, hop2, src] → size 4 → hops = 2 + val results = + listOf( + makeResult(requestId = 1, receivedDateMillis = 1005L * MS_PER_SEC, intermediateRoute = listOf(10, 20)), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(2, point.forwardHops) + } + + @Test + fun directRoute_yieldsZeroHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // Direct route: no intermediate hops → fullRoute = [dest, src] → size 2 → hops = 0 + // route_back also empty intermediate → fullRouteBack = [src, dest] → size 2 → hops = 0 + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1002L * MS_PER_SEC, + intermediateRoute = emptyList(), + intermediateRouteBack = emptyList(), + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(0, point.forwardHops) + // route_back with empty intermediateRouteBack: snr_back will be empty, + // so fullRouteDiscovery won't wrap it → raw route_back is empty → returnHops = null + assertNull(point.returnHops) + } + + @Test + fun computesRoundTripSeconds() { + val requestTime = 2000L * MS_PER_SEC // 2_000_000 ms + val resultTime = requestTime + 3500L // 3.5 seconds later in millis + val requests = listOf(makeRequest(id = 1, receivedDateMillis = requestTime)) + val results = listOf(makeResult(requestId = 1, receivedDateMillis = resultTime)) + + val point = resolveTraceroutePoints(requests, results).first() + + val rtt = assertNotNull(point.roundTripSeconds) + assertEquals(3.5, rtt, 0.01) + } + + @Test + fun noMatchingResult_yieldsNulls() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // Result has a different requestId, so it won't match + val results = listOf(makeResult(requestId = 99, receivedDateMillis = 1005L * MS_PER_SEC)) + + val point = resolveTraceroutePoints(requests, results).first() + + assertNull(point.result) + assertNull(point.forwardHops) + assertNull(point.returnHops) + assertNull(point.roundTripSeconds) + } + + @Test + fun emptyInputs_returnsEmpty() { + assertEquals(emptyList(), resolveTraceroutePoints(emptyList(), emptyList())) + } + + @Test + fun multipleRequests_preservesOrder() { + val req1 = makeRequest(id = 1, receivedDateMillis = 3000L * MS_PER_SEC) + val req2 = makeRequest(id = 2, receivedDateMillis = 4000L * MS_PER_SEC) + val res1 = makeResult(requestId = 1, receivedDateMillis = 3005L * MS_PER_SEC) + val res2 = makeResult(requestId = 2, receivedDateMillis = 4005L * MS_PER_SEC) + + val points = resolveTraceroutePoints(listOf(req1, req2), listOf(res1, res2)) + + assertEquals(2, points.size) + assertEquals(3000.0, points[0].timeSeconds) + assertEquals(4000.0, points[1].timeSeconds) + } + + @Test + fun emptyRouteBack_yieldsNullReturnHops() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // 1 intermediate hop forward, but null route_back → no return path data + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1005L * MS_PER_SEC, + intermediateRoute = listOf(3), + intermediateRouteBack = null, + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(1, point.forwardHops) + assertNull(point.returnHops) + } + + @Test + fun returnHops_computedWhenRouteBackAvailable() { + val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) + // 1 intermediate hop on return path, with hop_start and snr_back set + // → fullRouteBack = [src, hop, dest] → size 3 → returnHops = 1 + val results = + listOf( + makeResult( + requestId = 1, + receivedDateMillis = 1005L * MS_PER_SEC, + intermediateRoute = listOf(3), + intermediateRouteBack = listOf(3), + hopStart = 3, + ), + ) + + val point = resolveTraceroutePoints(requests, results).first() + + assertEquals(1, point.forwardHops) + assertEquals(1, point.returnHops) + } +}