add colour coding to traceroutes (#3227)

pull/3230/head
Dane Evans 2025-09-29 02:53:33 +10:00 zatwierdzone przez GitHub
rodzic 3951ebb375
commit cd010c4967
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
3 zmienionych plików z 91 dodań i 20 usunięć

Wyświetl plik

@ -94,6 +94,7 @@ import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.components.TopLevelNavIcon
import com.geeksville.mesh.ui.metrics.annotateTraceroute
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -206,7 +207,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
traceRouteResponse?.let { response ->
SimpleAlertDialog(
title = R.string.traceroute,
text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text(text = response) } },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response))
}
},
dismissText = stringResource(id = R.string.okay),
onDismiss = { uIViewModel.clearTracerouteResponse() },
)

Wyświetl plik

@ -52,6 +52,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -62,11 +67,17 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import java.text.DateFormat
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
@ -74,10 +85,10 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
var showDialog by remember { mutableStateOf<String?>(null) }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
if (showDialog != null) {
val message = showDialog ?: return
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
SimpleAlertDialog(
title = R.string.traceroute,
text = { SelectionContainer { Text(text = message) } },
@ -88,7 +99,7 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val result =
remember(state.tracerouteRequests) {
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id }
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
@ -97,21 +108,35 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
val tracerouteDetailsAnnotated: AnnotatedString? =
result?.let { res ->
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
val annotatedBase =
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
buildAnnotatedString {
append(annotatedBase)
append("\n\nDuration: ${"%.1f".format(seconds)} s")
}
} else {
// For cases where there's a result but no full route, display plain text
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
}
}
Box {
TracerouteItem(
icon = icon,
text = "$time - $text",
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
if (result != null) {
val full = route
if (full != null && full.routeList.isNotEmpty() && full.routeBackList.isNotEmpty()) {
val elapsedMs = (result.received_date - log.received_date).coerceAtLeast(0)
val seconds = elapsedMs.toDouble() / MS_PER_SEC
val base = result.fromRadio.packet.getTracerouteResponse(::getUsername)
showDialog = "$base\n\nDuration: ${"%.1f".format(seconds)} s"
} else {
showDialog = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (tracerouteDetailsAnnotated != null) {
showDialog = tracerouteDetailsAnnotated
} else if (result != null) {
// Fallback for results that couldn't be fully annotated but have basic info
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (basicInfo != null) {
showDialog = AnnotatedString(basicInfo)
}
}
},
@ -159,13 +184,14 @@ private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier =
}
}
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
this == null -> {
stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff
}
routeCount <= 2 -> {
// A direct route means the sender and receiver are the only two nodes in the route.
routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
stringResource(R.string.traceroute_direct) to Icons.Default.Group
}
@ -175,11 +201,51 @@ private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVecto
}
else -> {
val (towards, back) = maxOf(0, routeCount - 2) to maxOf(0, routeBackCount - 2)
// Asymmetric route
val towards = maxOf(0, routeCount - 2)
val back = maxOf(0, routeBackCount - 2)
stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups
}
}
/**
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
*
* @param inString The raw string output from a traceroute response.
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
*/
@Composable
fun annotateTraceroute(inString: String?): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "⇊ -8.75 dB SNR"
if (line.trimStart().startsWith("")) {
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
else -> MaterialTheme.colorScheme.StatusOrange
}
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
} else {
// Append line as is if SNR value cannot be parsed
append(line)
}
} else {
// Append non-SNR lines as is
append(line)
}
}
}
}
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {

Wyświetl plik

@ -53,11 +53,11 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
private const val SNR_GOOD_THRESHOLD = -7f
private const val SNR_FAIR_THRESHOLD = -15f
const val SNR_GOOD_THRESHOLD = -7f
const val SNR_FAIR_THRESHOLD = -15f
private const val RSSI_GOOD_THRESHOLD = -115
private const val RSSI_FAIR_THRESHOLD = -126
const val RSSI_GOOD_THRESHOLD = -115
const val RSSI_FAIR_THRESHOLD = -126
@Stable
private enum class Quality(