kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
add colour coding to traceroutes (#3227)
rodzic
3951ebb375
commit
cd010c4967
|
@ -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() },
|
||||
)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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(
|
||||
|
|
Ładowanie…
Reference in New Issue