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)
+ }
+}