mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(charts): adopt Vico best practices, add sensor data, and migrate TracerouteLog (#5026)
This commit is contained in:
parent
e01c4abae7
commit
9c0e9b82d6
20 changed files with 2062 additions and 507 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue