feat(charts): More charts ui/ux tweaks (#4520)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-10 17:02:06 -06:00 committed by GitHub
parent 53b5707a41
commit d252fde289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 586 additions and 467 deletions

View file

@ -446,11 +446,13 @@
<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="one_hour_short">1H</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
<string name="one_week">1W</string>
<string name="two_weeks">2W</string>
<string name="four_weeks">4W</string>
<string name="one_month">1M</string>
<string name="max">Max</string>
<string name="unknown_age">Unknown Age</string>
<string name="copy">Copy</string>
@ -1175,4 +1177,5 @@
<string name="scan_nfc_text">Bring your device close to the NFC tag to scan.</string>
<string name="generate_qr_code">Generate QR Code</string>
<string name="nfc_disabled">NFC is disabled. Please enable it in system settings.</string>
<string name="all_time">All</string>
</resources>

View file

@ -27,6 +27,8 @@ configure<LibraryExtension> {
namespace = "org.meshtastic.feature.node"
defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = "DEBUG_KEY" }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
@ -66,4 +68,5 @@ dependencies {
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.robolectric)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.rounded.Info
@ -32,16 +33,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
@ -66,7 +65,6 @@ import org.meshtastic.core.strings.logs
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.detail.NodeRequestEffect
/**
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
@ -147,18 +145,19 @@ fun AdaptiveMetricLayout(
@Composable
@Suppress("LongMethod")
fun <T> BaseMetricScreen(
viewModel: MetricsViewModel,
onNavigateUp: () -> Unit,
telemetryType: TelemetryType?,
titleRes: StringResource,
nodeName: String,
data: List<T>,
timeProvider: (T) -> Double,
infoData: List<InfoDialogData> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onRequestTelemetry: (() -> Unit)? = null,
chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
listPart: @Composable (Modifier, Double?, (Double) -> Unit) -> Unit,
listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit,
controlPart: @Composable () -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) }
val lazyListState = rememberLazyListState()
@ -166,21 +165,10 @@ fun <T> BaseMetricScreen(
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
title = nodeName,
subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
@ -193,7 +181,10 @@ fun <T> BaseMetricScreen(
}
}
if (telemetryType != null) {
IconButton(onClick = { viewModel.requestTelemetry(telemetryType) }) {
IconButton(
onClick = { onRequestTelemetry?.invoke() },
modifier = Modifier.testTag("refresh_button"),
) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
@ -208,6 +199,8 @@ fun <T> BaseMetricScreen(
LegendInfoDialog(infoData = infoData, onDismiss = { displayInfoDialog = false })
}
controlPart()
AdaptiveMetricLayout(
chartPart = { modifier ->
chartPart(modifier, selectedX, vicoScrollState) { x ->
@ -219,7 +212,7 @@ fun <T> BaseMetricScreen(
}
},
listPart = { modifier ->
listPart(modifier, selectedX) { x ->
listPart(modifier, selectedX, lazyListState) { x ->
selectedX = x
coroutineScope.launch {
vicoScrollState.animateScroll(Scroll.Absolute.x(x, CommonCharts.SCROLL_BIAS))

View file

@ -76,7 +76,6 @@ object CommonCharts {
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
@ -105,14 +104,17 @@ object CommonCharts {
val zoom = if (context is CartesianDrawingContext) context.zoom else 1f
val visibleSpan = xLength / zoom
val formatter =
when {
visibleSpan <= 3600 -> TIME_SECONDS_FORMAT // < 1 hour visible
visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT // < 2 days visible
visibleSpan <= 86400 * 14 -> DATE_TIME_MINUTE_FORMAT // < 2 weeks visible
else -> DATE_FORMAT
when {
visibleSpan <= 3600 -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible
visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible
visibleSpan <= 86400 * 14 -> {
// < 2 weeks visible: separate date and time with a newline
val dateStr = DATE_FORMAT.format(date)
val timeStr = TIME_MINUTE_FORMAT.format(date)
"$dateStr\n$timeStr"
}
formatter.format(date)
else -> DATE_FORMAT.format(date)
}
}
}

View file

@ -37,6 +37,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -52,6 +53,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -79,6 +81,7 @@ 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.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@ -122,13 +125,27 @@ private val LEGEND_DATA =
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val data = state.deviceMetrics
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.deviceMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
val snackbarHostState = remember { SnackbarHostState() }
val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } }
val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } }
val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } }
val hasAirUtil = remember(data) { data.any { it.device_metrics?.air_util_tx != null } }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
val filteredLegendData =
remember(hasBattery, hasVoltage, hasChUtil, hasAirUtil) {
LEGEND_DATA.filter { d ->
@ -167,13 +184,23 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
}
BaseMetricScreen(
viewModel = viewModel,
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.DEVICE,
titleRes = Res.string.device_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.time ?: 0).toDouble() },
infoData = infoItems,
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
availableTimeFrames = availableTimeFrames,
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
DeviceMetricsChart(
modifier = modifier,
@ -184,8 +211,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
onPointSelected = onPointSelected,
)
},
listPart = { modifier, selectedX, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize()) {
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, telemetry ->
DeviceMetricsCard(
telemetry = telemetry,

View file

@ -29,28 +29,18 @@ 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.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -59,12 +49,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.env_metrics_log
@ -72,8 +58,6 @@ import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.iaq_definition
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
@ -83,28 +67,19 @@ import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
import org.meshtastic.proto.Telemetry
@Suppress("LongMethod")
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val graphData by viewModel.environmentGraphingData.collectAsStateWithLifecycle()
val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val graphData = environmentState.environmentMetricsForGraphing(state.isFahrenheit)
val data = graphData.metrics
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
@ -117,99 +92,47 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
}
}
val processedTelemetries: List<Telemetry> =
if (state.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
val temperatureFahrenheit = em.temperature?.let { celsiusToFahrenheit(it) }
val soilTemperatureFahrenheit = em.soil_temperature?.let { celsiusToFahrenheit(it) }
telemetry.copy(
environment_metrics =
em.copy(temperature = temperatureFahrenheit, soil_temperature = soilTemperatureFahrenheit),
)
}
} else {
data
}
var displayInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.env_metrics_log) +
" (${processedTelemetries.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
IconButton(onClick = { displayInfoDialog = true }) {
Icon(imageVector = Icons.Rounded.Info, contentDescription = stringResource(Res.string.info))
}
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }) {
androidx.compose.material3.Icon(
imageVector = MeshtasticIcons.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {},
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.ENVIRONMENT,
titleRes = Res.string.env_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = filteredTelemetries,
timeProvider = { (it.time ?: 0).toDouble() },
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
availableTimeFrames = availableTimeFrames,
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
onDismiss = { displayInfoDialog = false },
)
}
AdaptiveMetricLayout(
chartPart = { modifier ->
EnvironmentMetricsChart(
modifier = modifier,
telemetries = processedTelemetries.reversed(),
graphData = graphData,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = processedTelemetries.indexOfFirst { (it.time ?: 0).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
},
listPart = { modifier ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(processedTelemetries) { _, telemetry ->
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = {
selectedX = (telemetry.time ?: 0).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), SCROLL_BIAS),
)
}
},
)
}
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
EnvironmentMetricsChart(
modifier = modifier,
telemetries = filteredTelemetries.reversed(),
graphData = graphData,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = onPointSelected,
)
}
}
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(filteredTelemetries) { _, telemetry ->
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
)
}
}
},
)
}
@Composable
@ -374,9 +297,9 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
)
}
if (hasCurrent) {
val current = envMetrics.current!!
val currentValue = envMetrics.current!!
Text(
text = "%s %.2f mA".format(stringResource(Res.string.current), current),
text = "%s %.2f mA".format(stringResource(Res.string.current), currentValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -443,8 +366,6 @@ private fun EnvironmentMetricsCard(
isSelected: Boolean,
onClick: () -> Unit,
) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,

View file

@ -41,7 +41,8 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_temperature
},
SOIL_MOISTURE(Purple) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_moisture?.toFloat()
override fun getValue(telemetry: Telemetry) =
telemetry.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE }?.toFloat()
},
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.barometric_pressure
@ -50,7 +51,8 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.gas_resistance
},
IAQ(Cyan) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.iaq?.toFloat()
override fun getValue(telemetry: Telemetry) =
telemetry.environment_metrics?.iaq?.takeIf { it != Int.MIN_VALUE }?.toFloat()
},
LUX(Gold) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.lux
@ -136,7 +138,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
}
// Soil Moisture
val soilMoistures = telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != 0 } }
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() })
@ -144,7 +147,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
}
// IAQ
val iaqs = telemetries.mapNotNull { it.environment_metrics?.iaq?.takeIf { it != 0 } }
val iaqs = telemetries.mapNotNull { it.environment_metrics?.iaq?.takeIf { it != Int.MIN_VALUE } }
if (iaqs.isNotEmpty()) {
minValues.add(iaqs.minOf { it.toFloat() })
maxValues.add(iaqs.maxOf { it.toFloat() })

View file

@ -54,10 +54,9 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
@ -72,23 +71,30 @@ import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 &&
((rx_snr ?: 0f) != 0f && (rx_rssi ?: 0) != 0) &&
((hop_start ?: 0) > 0 && (hop_start ?: 0) - (hop_limit ?: 0) == 0)
private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && ((rx_snr ?: 0f) != 0f || (rx_rssi ?: 0) != 0)
private fun Telemetry.hasValidEnvironmentMetrics(): Boolean {
val metrics = this.environment_metrics ?: return false
return metrics.relative_humidity != null && metrics.temperature != null && metrics.temperature?.isNaN() != true
}
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
@ -115,11 +121,11 @@ constructor(
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum }
private fun MeshLog.hasValidTraceroute(dest: Int?): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest }
private fun MeshLog.hasValidNeighborInfo(): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum }
private fun MeshLog.hasValidNeighborInfo(dest: Int?): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest }
/**
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
@ -166,16 +172,6 @@ constructor(
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums = positionedNodeNums(),
)
fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability =
tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute)
fun positionedNodeNums(): Set<Int> =
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
@ -199,13 +195,65 @@ constructor(
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) }
}
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
private val _state = MutableStateFlow(MetricsState.Empty)
val state: StateFlow<MetricsState> = _state
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
private val environmentState = MutableStateFlow(EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _timeFrame
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(_state, environmentState) { state, envState ->
val stateOldest = state.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: (System.currentTimeMillis() / 1000L)
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Exposes filtered and unit-converted environment metrics for the UI. */
val filteredEnvironmentMetrics: StateFlow<List<Telemetry>> =
combine(environmentState, _timeFrame, _state) { envState, timeFrame, state ->
val threshold = timeFrame.timeThreshold()
val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
if (state.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
telemetry.copy(
environment_metrics =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
} else {
data
}
}
.stateInWhileSubscribed(emptyList())
/** Exposes graphing data specifically for the filtered environment metrics. */
val environmentGraphingData: StateFlow<EnvironmentGraphingData> =
filteredEnvironmentMetrics
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
/** Exposes filtered and decoded pax metrics for the UI. */
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
combine(_state, _timeFrame) { state, timeFrame ->
val threshold = timeFrame.timeThreshold()
state.paxMetrics
.filter { (it.received_date / 1000) >= threshold }
.mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } }
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
@ -215,10 +263,6 @@ constructor(
val lastRequestNeighborsTime: StateFlow<Long?> =
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
fun requestUserInfo() {
destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
}
fun requestPosition() {
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
}
@ -298,7 +342,7 @@ constructor(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun initializeFlows() {
jobs?.cancel()
val currentDestNum = destNum
@ -315,7 +359,7 @@ constructor(
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(currentDestNum)
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
val hwModel = actualNode.user.hw_model?.value ?: 0
val hwModel = actualNode.user.hw_model.value
val deviceHardware =
deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv)
@ -348,24 +392,22 @@ constructor(
launch {
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
for (item in telemetry) {
if (item.device_metrics != null) device.add(item)
if (item.power_metrics != null) power.add(item)
if (item.host_metrics != null) host.add(item)
if (item.hasValidEnvironmentMetrics()) env.add(item)
}
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.device_metrics != null },
powerMetrics = telemetry.filter { it.power_metrics != null },
hostMetrics = telemetry.filter { it.host_metrics != null },
)
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.environment_metrics != null &&
it.environment_metrics?.relative_humidity != null &&
it.environment_metrics?.temperature != null &&
it.environment_metrics?.temperature?.isNaN()?.not() == true
},
)
state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host)
}
environmentState.update { it.copy(environmentMetrics = env) }
}
}
@ -384,7 +426,7 @@ constructor(
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteRequests = request.filter { it.hasValidTraceroute(currentDestNum) },
tracerouteResults = response,
)
}
@ -399,7 +441,8 @@ constructor(
) { request, response ->
_state.update { state ->
state.copy(
neighborInfoRequests = request.filter { it.hasValidNeighborInfo() },
neighborInfoRequests =
request.filter { it.hasValidNeighborInfo(currentDestNum) },
neighborInfoResults = response,
)
}
@ -504,4 +547,37 @@ constructor(
Logger.e(ex) { "Can't write file error" }
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax
}
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
}
// Fallback: Try direct base64 or bytes from raw_message
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return ProtoPaxcount.ADAPTER.decode(bytes)
}
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
} catch (e: NumberFormatException) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
}
return null
}
}

View file

@ -28,24 +28,16 @@ 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.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -54,7 +46,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
@ -62,8 +53,6 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.MeshLog
@ -71,21 +60,17 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.no_pax_metrics_logs
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.PortNum
import java.text.DateFormat
import java.util.Date
import org.meshtastic.proto.Paxcount as ProtoPaxcount
@ -191,12 +176,12 @@ private fun PaxMetricsChart(
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle()
val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by metricsViewModel.availableTimeFrames.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
val dateFormat = DateFormat.getDateTimeInstance()
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
@ -209,17 +194,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
}
}
val dateFormat = DateFormat.getDateTimeInstance()
// Only show logs that can be decoded as ProtoPaxcount
val paxMetrics =
state.paxMetrics.mapNotNull { log ->
val pax = decodePaxFromLog(log)
if (pax != null) {
Pair(log, pax)
} else {
null
}
}
// Prepare data for graph
val graphData =
paxMetrics
@ -232,147 +206,62 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
val bleSeries = graphData.map { it.first to it.second }
val wifiSeries = graphData.map { it.first to it.third }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.pax_metrics_log) +
" (${paxMetrics.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
if (!state.isLocal) {
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.PAX,
titleRes = Res.string.pax_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
timeProvider = { (it.first.received_date / 1000).toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
availableTimeFrames = availableTimeFrames,
onTimeFrameSelected = metricsViewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Graph
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
if (graphData.isNotEmpty()) {
AdaptiveMetricLayout(
chartPart = { modifier ->
PaxMetricsChart(
modifier = modifier,
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = paxMetrics.indexOfFirst { (it.first.received_date / 1000).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
},
listPart = { modifier ->
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(Res.string.no_pax_metrics_logs),
modifier = modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
state = lazyListState,
) {
itemsIndexed(paxMetrics) { _, (log, pax) ->
PaxMetricsItem(
log = log,
pax = pax,
dateFormat = dateFormat,
isSelected = (log.received_date / 1000).toDouble() == selectedX,
onClick = {
selectedX = (log.received_date / 1000).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x((log.received_date / 1000).toDouble(), 0.5f),
)
}
},
)
}
}
}
},
PaxMetricsChart(
modifier = modifier,
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = onPointSelected,
)
}
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(Res.string.no_pax_metrics_logs),
modifier = modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
// Empty state if no graph data
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(Res.string.no_pax_metrics_logs),
modifier = Modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(horizontal = 16.dp),
) {
itemsIndexed(paxMetrics) { _, (log, pax) ->
PaxMetricsItem(
log = log,
pax = pax,
dateFormat = dateFormat,
isSelected = (log.received_date / 1000).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / 1000).toDouble()) },
)
}
}
}
}
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
var result: ProtoPaxcount? = null
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) result = pax
}
} catch (e: Exception) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e)
}
// Fallback: Try direct base64 or bytes from raw_message
if (result == null) {
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val pax = ProtoPaxcount.ADAPTER.decode(bytes)
result = pax
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val pax = ProtoPaxcount.ADAPTER.decode(bytes)
result = pax
}
} catch (e: Exception) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e)
}
}
return result
}
@Suppress("MagicNumber")
fun unescapeProtoString(escaped: String): ByteArray {
val out = mutableListOf<Byte>()
var i = 0
while (i < escaped.length) {
if (escaped[i] == '\\' && i + 3 < escaped.length && escaped[i + 1].isDigit()) {
// Octal escape: \\ddd
val octal = escaped.substring(i + 1, i + 4)
out.add(octal.toInt(8).toByte())
i += 4
} else {
out.add(escaped[i].code.toByte())
i++
}
}
return out.toByteArray()
},
)
}
@Composable

View file

@ -31,20 +31,12 @@ 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.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -53,7 +45,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -64,7 +55,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -73,8 +63,6 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
@ -83,10 +71,8 @@ import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -121,19 +107,15 @@ private val LEGEND_DATA =
),
)
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val data = state.powerMetrics
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.powerMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val lazyListState = rememberLazyListState()
val vicoScrollState = rememberVicoScrollState()
val coroutineScope = rememberCoroutineScope()
var selectedX by remember { mutableStateOf<Double?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
@ -146,80 +128,60 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
}
}
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.power_metrics_log) + " (${data.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) {
Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null)
}
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.POWER,
titleRes = Res.string.power_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.time ?: 0).toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) },
controlPart = {
Column {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
availableTimeFrames = availableTimeFrames,
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PowerChannel.entries.forEach { channel ->
FilterChip(
selected = selectedChannel == channel,
onClick = { selectedChannel = channel },
label = { Text(stringResource(channel.strRes)) },
)
}
},
onClickChip = {},
}
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
PowerMetricsChart(
modifier = modifier,
telemetries = data.reversed(),
selectedChannel = selectedChannel,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = onPointSelected,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PowerChannel.entries.forEach { channel ->
FilterChip(
selected = selectedChannel == channel,
onClick = { selectedChannel = channel },
label = { Text(stringResource(channel.strRes)) },
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, telemetry ->
PowerMetricsCard(
telemetry = telemetry,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
)
}
}
AdaptiveMetricLayout(
chartPart = { modifier ->
PowerMetricsChart(
modifier = modifier,
telemetries = data.reversed(),
selectedChannel = selectedChannel,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = data.indexOfFirst { (it.time ?: 0).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
},
listPart = { modifier ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, telemetry ->
PowerMetricsCard(
telemetry = telemetry,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = {
selectedX = (telemetry.time ?: 0).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), 0.5f),
)
}
},
)
}
}
},
)
}
}
},
)
}
@Suppress("LongMethod")
@ -242,7 +204,7 @@ private fun PowerMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(1f)) {
when (color.copy(alpha = 1f)) {
currentColor -> "Current: %.0f mA".format(value)
voltageColor -> "Voltage: %.1f V".format(value)
else -> "%.1f".format(value)

View file

@ -36,6 +36,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -66,6 +68,7 @@ import org.meshtastic.core.strings.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
@ -85,20 +88,44 @@ private val LEGEND_DATA =
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val data = state.signalMetrics
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.signalMetrics.filter { (it.rx_time ?: 0).toLong() >= timeFrame.timeThreshold() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
BaseMetricScreen(
viewModel = viewModel,
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.LOCAL_STATS,
titleRes = Res.string.signal_quality,
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { (it.rx_time ?: 0).toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
infoData =
listOf(
InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color),
InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color),
),
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,
availableTimeFrames = availableTimeFrames,
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
SignalMetricsChart(
modifier = modifier,
@ -108,8 +135,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
onPointSelected = onPointSelected,
)
},
listPart = { modifier, selectedX, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize()) {
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(data) { _, meshPacket ->
SignalMetricsCard(
meshPacket = meshPacket,

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.feature.node.model.TimeFrame
@Suppress("LambdaParameterEventTrailing")
@Composable
fun TimeFrameSelector(
selectedTimeFrame: TimeFrame,
availableTimeFrames: List<TimeFrame>,
onTimeFrameSelected: (TimeFrame) -> Unit,
modifier: Modifier = Modifier,
) {
if (availableTimeFrames.size <= 1) return
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
availableTimeFrames.forEachIndexed { index, timeFrame ->
val text = stringResource(timeFrame.strRes)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index, availableTimeFrames.size),
onClick = { onTimeFrameSelected(timeFrame) },
selected = timeFrame == selectedTimeFrame,
label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
}

View file

@ -65,6 +65,21 @@ data class MetricsState(
fun hasPaxMetrics() = paxMetrics.isNotEmpty()
/** Finds the oldest timestamp (in seconds) among all collected metric types. */
@Suppress("MagicNumber")
fun oldestTimestampSeconds(): Long? {
val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).mapNotNull { it.time?.toLong() }
val signalTimes = signalMetrics.mapNotNull { it.rx_time?.toLong() }
val logTimes =
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
it.received_date / 1000L
}
val positionTimes = positionLogs.mapNotNull { it.time?.toLong() }
val allTimes = telemetryTimes + signalTimes + logTimes + positionTimes
return allTimes.minOrNull()
}
companion object {
val Empty = MetricsState()
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.all_time
import org.meshtastic.core.strings.one_hour_short
import org.meshtastic.core.strings.one_month
import org.meshtastic.core.strings.one_week
import org.meshtastic.core.strings.twenty_four_hours
import org.meshtastic.core.strings.two_weeks
@Suppress("MagicNumber")
enum class TimeFrame(val strRes: StringResource, val seconds: Long) {
ONE_HOUR(Res.string.one_hour_short, 3600),
TWENTY_FOUR_HOURS(Res.string.twenty_four_hours, 86400),
SEVEN_DAYS(Res.string.one_week, 604800),
TWO_WEEKS(Res.string.two_weeks, 1209600),
ONE_MONTH(Res.string.one_month, 2592000),
ALL_TIME(Res.string.all_time, 0),
;
fun timeThreshold(now: Long = System.currentTimeMillis() / 1000L): Long {
if (this == ALL_TIME) return 0
return now - seconds
}
/**
* Checks if this time frame is relevant given the oldest available data point. We show the option if the data
* extends at least into this timeframe.
*/
fun isAvailable(oldestTimestampSeconds: Long, now: Long = System.currentTimeMillis() / 1000L): Boolean {
if (this == ALL_TIME || this == ONE_HOUR) return true
val rangeSeconds = now - oldestTimestampSeconds
return rangeSeconds >= seconds
}
}

View file

@ -0,0 +1,95 @@
/*
* 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 androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.ui.theme.AppTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class BaseMetricScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun baseMetricScreen_displaysTitleAndNodeName() {
val nodeName = "Test Node 123"
val testData = listOf("Item 1", "Item 2")
composeTestRule.setContent {
AppTheme {
BaseMetricScreen(
onNavigateUp = {},
telemetryType = TelemetryType.DEVICE,
titleRes = Res.string.device_metrics_log,
nodeName = nodeName,
data = testData,
timeProvider = { 0.0 },
chartPart = { _, _, _, _ -> Text("Chart Placeholder") },
listPart = { _, _, _, _ -> Text("List Placeholder") },
)
}
}
// Verify Node Name is displayed (MainAppBar title)
composeTestRule.onNodeWithText(nodeName).assertIsDisplayed()
// Verify Placeholders are displayed
composeTestRule.onNodeWithText("Chart Placeholder").assertIsDisplayed()
composeTestRule.onNodeWithText("List Placeholder").assertIsDisplayed()
}
@Test
fun baseMetricScreen_refreshButtonTriggersCallback() {
var refreshClicked = false
val testData = emptyList<String>()
composeTestRule.setContent {
AppTheme {
BaseMetricScreen(
onNavigateUp = {},
telemetryType = TelemetryType.DEVICE,
titleRes = Res.string.device_metrics_log,
nodeName = "Node",
data = testData,
timeProvider = { 0.0 },
onRequestTelemetry = { refreshClicked = true },
chartPart = { _, _, _, _ -> },
listPart = { _, _, _, _ -> },
)
}
}
composeTestRule.onNodeWithTag("refresh_button").performClick()
assertTrue("Refresh callback should be triggered", refreshClicked)
}
}