Skip to content

Commit cd010c4

Browse files
authored
add colour coding to traceroutes (#3227)
1 parent 3951ebb commit cd010c4

File tree

3 files changed

+91
-20
lines changed

3 files changed

+91
-20
lines changed

app/src/main/java/com/geeksville/mesh/ui/Main.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import com.geeksville.mesh.ui.common.components.MainAppBar
9494
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
9595
import com.geeksville.mesh.ui.connections.DeviceType
9696
import com.geeksville.mesh.ui.connections.components.TopLevelNavIcon
97+
import com.geeksville.mesh.ui.metrics.annotateTraceroute
9798
import com.geeksville.mesh.ui.node.components.NodeMenuAction
9899
import com.geeksville.mesh.ui.sharing.SharedContactDialog
99100
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -206,7 +207,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
206207
traceRouteResponse?.let { response ->
207208
SimpleAlertDialog(
208209
title = R.string.traceroute,
209-
text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text(text = response) } },
210+
text = {
211+
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
212+
Text(text = annotateTraceroute(response))
213+
}
214+
},
210215
dismissText = stringResource(id = R.string.okay),
211216
onDismiss = { uIViewModel.clearTracerouteResponse() },
212217
)

app/src/main/java/com/geeksville/mesh/ui/metrics/TracerouteLog.kt

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ import androidx.compose.ui.Modifier
5252
import androidx.compose.ui.graphics.vector.ImageVector
5353
import androidx.compose.ui.res.pluralStringResource
5454
import androidx.compose.ui.res.stringResource
55+
import androidx.compose.ui.text.AnnotatedString
56+
import androidx.compose.ui.text.SpanStyle
57+
import androidx.compose.ui.text.buildAnnotatedString
58+
import androidx.compose.ui.text.font.FontWeight
59+
import androidx.compose.ui.text.withStyle
5560
import androidx.compose.ui.tooling.preview.PreviewLightDark
5661
import androidx.compose.ui.unit.dp
5762
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -62,22 +67,28 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
6267
import org.meshtastic.core.model.fullRouteDiscovery
6368
import org.meshtastic.core.model.getTracerouteResponse
6469
import org.meshtastic.core.strings.R
70+
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
71+
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
6572
import org.meshtastic.core.ui.component.SimpleAlertDialog
6673
import org.meshtastic.core.ui.theme.AppTheme
74+
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
75+
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
76+
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
6777
import java.text.DateFormat
6878

6979
@OptIn(ExperimentalFoundationApi::class)
80+
@Suppress("LongMethod")
7081
@Composable
7182
fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel()) {
7283
val state by viewModel.state.collectAsStateWithLifecycle()
7384
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
7485

7586
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
7687

77-
var showDialog by remember { mutableStateOf<String?>(null) }
88+
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
7889

7990
if (showDialog != null) {
80-
val message = showDialog ?: return
91+
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
8192
SimpleAlertDialog(
8293
title = R.string.traceroute,
8394
text = { SelectionContainer { Text(text = message) } },
@@ -88,7 +99,7 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
8899
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
89100
items(state.tracerouteRequests, key = { it.uuid }) { log ->
90101
val result =
91-
remember(state.tracerouteRequests) {
102+
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
92103
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id }
93104
}
94105
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
@@ -97,21 +108,35 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
97108
val (text, icon) = route.getTextAndIcon()
98109
var expanded by remember { mutableStateOf(false) }
99110

111+
val tracerouteDetailsAnnotated: AnnotatedString? =
112+
result?.let { res ->
113+
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
114+
val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
115+
val annotatedBase =
116+
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
117+
buildAnnotatedString {
118+
append(annotatedBase)
119+
append("\n\nDuration: ${"%.1f".format(seconds)} s")
120+
}
121+
} else {
122+
// For cases where there's a result but no full route, display plain text
123+
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
124+
}
125+
}
126+
100127
Box {
101128
TracerouteItem(
102129
icon = icon,
103130
text = "$time - $text",
104131
modifier =
105132
Modifier.combinedClickable(onLongClick = { expanded = true }) {
106-
if (result != null) {
107-
val full = route
108-
if (full != null && full.routeList.isNotEmpty() && full.routeBackList.isNotEmpty()) {
109-
val elapsedMs = (result.received_date - log.received_date).coerceAtLeast(0)
110-
val seconds = elapsedMs.toDouble() / MS_PER_SEC
111-
val base = result.fromRadio.packet.getTracerouteResponse(::getUsername)
112-
showDialog = "$base\n\nDuration: ${"%.1f".format(seconds)} s"
113-
} else {
114-
showDialog = result.fromRadio.packet.getTracerouteResponse(::getUsername)
133+
if (tracerouteDetailsAnnotated != null) {
134+
showDialog = tracerouteDetailsAnnotated
135+
} else if (result != null) {
136+
// Fallback for results that couldn't be fully annotated but have basic info
137+
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
138+
if (basicInfo != null) {
139+
showDialog = AnnotatedString(basicInfo)
115140
}
116141
}
117142
},
@@ -159,13 +184,14 @@ private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier =
159184
}
160185
}
161186

187+
/** Generates a display string and icon based on the route discovery information. */
162188
@Composable
163189
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
164190
this == null -> {
165191
stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff
166192
}
167-
168-
routeCount <= 2 -> {
193+
// A direct route means the sender and receiver are the only two nodes in the route.
194+
routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
169195
stringResource(R.string.traceroute_direct) to Icons.Default.Group
170196
}
171197

@@ -175,11 +201,51 @@ private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVecto
175201
}
176202

177203
else -> {
178-
val (towards, back) = maxOf(0, routeCount - 2) to maxOf(0, routeBackCount - 2)
204+
// Asymmetric route
205+
val towards = maxOf(0, routeCount - 2)
206+
val back = maxOf(0, routeBackCount - 2)
179207
stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups
180208
}
181209
}
182210

211+
/**
212+
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
213+
*
214+
* @param inString The raw string output from a traceroute response.
215+
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
216+
*/
217+
@Composable
218+
fun annotateTraceroute(inString: String?): AnnotatedString {
219+
if (inString == null) return buildAnnotatedString { append("") }
220+
return buildAnnotatedString {
221+
inString.lines().forEachIndexed { i, line ->
222+
if (i > 0) append("\n")
223+
// Example line: "⇊ -8.75 dB SNR"
224+
if (line.trimStart().startsWith("")) {
225+
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
226+
val snrMatch = snrRegex.find(line)
227+
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
228+
229+
if (snrValue != null) {
230+
val snrColor =
231+
when {
232+
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
233+
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
234+
else -> MaterialTheme.colorScheme.StatusOrange
235+
}
236+
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
237+
} else {
238+
// Append line as is if SNR value cannot be parsed
239+
append(line)
240+
}
241+
} else {
242+
// Append non-SNR lines as is
243+
append(line)
244+
}
245+
}
246+
}
247+
}
248+
183249
@PreviewLightDark
184250
@Composable
185251
private fun TracerouteItemPreview() {

core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
5353
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
5454
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
5555

56-
private const val SNR_GOOD_THRESHOLD = -7f
57-
private const val SNR_FAIR_THRESHOLD = -15f
56+
const val SNR_GOOD_THRESHOLD = -7f
57+
const val SNR_FAIR_THRESHOLD = -15f
5858

59-
private const val RSSI_GOOD_THRESHOLD = -115
60-
private const val RSSI_FAIR_THRESHOLD = -126
59+
const val RSSI_GOOD_THRESHOLD = -115
60+
const val RSSI_FAIR_THRESHOLD = -126
6161

6262
@Stable
6363
private enum class Quality(

0 commit comments

Comments
 (0)