feat(charts): adopt Vico best practices, add sensor data, and migrate TracerouteLog (#5026)

This commit is contained in:
James Rich 2026-04-09 18:44:59 -05:00 committed by GitHub
parent e01c4abae7
commit 9c0e9b82d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2062 additions and 507 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>()
// No node in the routes has a position — but first check endpoints
// Endpoints 1 and 3 are missing → MissingEndpoints takes precedence
val positioned = emptySet<Int>()
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<Int>()
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)
}
}

View file

@ -479,6 +479,17 @@
<string name="traceroute_time_and_text">%1$s - %2$s</string>
<string name="traceroute_route_towards_dest">Route traced toward destination:\n\n</string>
<string name="traceroute_route_back_to_us">Route traced back to us:\n\n</string>
<string name="traceroute_forward_hops">Forward Hops</string>
<string name="traceroute_return_hops">Return Hops</string>
<string name="traceroute_round_trip">Round Trip</string>
<string name="traceroute_no_response">No Response</string>
<string name="load_1_min">Load 1m</string>
<string name="load_5_min">Load 5m</string>
<string name="load_15_min">Load 15m</string>
<string name="load_1_min_description">One-minute system load average</string>
<string name="load_5_min_description">Five-minute system load average</string>
<string name="load_15_min_description">Fifteen-minute system load average</string>
<string name="free_memory_description">Available system memory in bytes</string>
<string name="one_hour_short">1H</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
@ -487,6 +498,10 @@
<string name="four_weeks">4W</string>
<string name="one_month">1M</string>
<string name="max">Max</string>
<string name="min">Min</string>
<string name="avg">Avg</string>
<string name="expand_chart">Expand chart</string>
<string name="collapse_chart">Collapse chart</string>
<string name="unknown_age">Unknown Age</string>
<string name="copy">Copy</string>
<string name="alert_bell_text">Alert Bell Character!</string>
@ -500,6 +515,11 @@
<string name="channel_1">Channel 1</string>
<string name="channel_2">Channel 2</string>
<string name="channel_3">Channel 3</string>
<string name="channel_4">Channel 4</string>
<string name="channel_5">Channel 5</string>
<string name="channel_6">Channel 6</string>
<string name="channel_7">Channel 7</string>
<string name="channel_8">Channel 8</string>
<string name="current">Current</string>
<string name="voltage">Voltage</string>
<string name="are_you_sure">Are you sure?</string>
@ -782,6 +802,14 @@
<string name="distance">Distance</string>
<string name="lux">Lux</string>
<string name="wind">Wind</string>
<string name="wind_speed">Wind Speed</string>
<string name="wind_gust">Wind Gust</string>
<string name="wind_lull">Wind Lull</string>
<string name="wind_direction">Wind Dir</string>
<string name="rainfall_1h">Rain (1h)</string>
<string name="rainfall_24h">Rain (24h)</string>
<string name="ir_lux">IR Lux</string>
<string name="white_lux">White Lux</string>
<string name="weight">Weight</string>
<string name="radiation">Radiation</string>
<string name="store_forward_config"><![CDATA[Store & Forward Config]]></string>

View file

@ -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 {

View file

@ -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<Axis.Position.Vertical.End>? = null,
bottomAxis: HorizontalAxis<Axis.Position.Horizontal.Bottom>? = null,
marker: CartesianMarker? = null,
decorations: List<Decoration> = 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<Float>, 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 <T> BaseMetricScreen(
@ -151,14 +228,20 @@ fun <T> BaseMetricScreen(
timeProvider: (T) -> Double,
infoData: List<InfoDialogData> = 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<Double?>(null) }
@ -172,6 +255,21 @@ fun <T> 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 <T> BaseMetricScreen(
controlPart()
AdaptiveMetricLayout(
isChartExpanded = isChartExpanded,
chartPart = { modifier ->
chartPart(modifier, selectedX, vicoScrollState) { x ->
selectedX = x

View file

@ -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.

View file

@ -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<Axis.Position.Horizontal.Bottom> = 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<LegendData>, modifier: Modifier = Modifier) {
fun Legend(
legendData: List<LegendData>,
modifier: Modifier = Modifier,
hiddenSet: Set<Int> = 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<InfoDialogData>, 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 = {})
}
}

View file

@ -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,

View file

@ -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<Int>()) }
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<LineCartesianLayer>()
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
},
)
}
}

View file

@ -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 {

View file

@ -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<Telemetry> = 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<Telemetry> = 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<Telemetry> = 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<Telemetry> = 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 }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<Telemetry>,
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))
}
}

View file

@ -14,201 +14,210 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.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

View file

@ -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,

View file

@ -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
}

View file

@ -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,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@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<MeshLog>, results: List<MeshLog>): List<TraceroutePoint> =
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<TraceroutePoint>,
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))
}
}

View file

@ -14,29 +14,44 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.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<String, ImageVector> = 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

View file

@ -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,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int> = listOf(3),
intermediateRouteBack: List<Int>? = 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)
}
}