feat: Traceroute map visualisation (#4002)

This commit is contained in:
Jord 2025-12-16 16:53:28 +00:00 committed by GitHub
parent 24f40b2005
commit 3dbc5108c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 917 additions and 60 deletions

View file

@ -52,10 +52,13 @@ import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
@ -152,6 +155,14 @@ constructor(
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums =
nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet(),
)
fun showAlert(
title: String,
message: String? = null,
@ -248,7 +259,7 @@ constructor(
Timber.d("ViewModel cleared")
}
val tracerouteResponse: LiveData<String?>
val tracerouteResponse: LiveData<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse.asLiveData()
fun clearTracerouteResponse() {

View file

@ -67,6 +67,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
@ -121,6 +122,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
composable<NodeDetailRoutes.TracerouteLog>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteLog>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
),
navDeepLink<NodeDetailRoutes.TracerouteLog>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteLog>()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = navController::navigateUp,
onViewOnMap = { requestId ->
navController.navigate(NodeDetailRoutes.TracerouteMap(args.destNum, requestId))
},
)
}
composable<NodeDetailRoutes.TracerouteMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteMap>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
),
navDeepLink<NodeDetailRoutes.TracerouteMap>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteMap>()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
onNavigateUp = navController::navigateUp,
)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
@ -163,14 +212,6 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
) {
it.destNum
}
NodeDetailRoutes.TracerouteLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,

View file

@ -80,6 +80,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.toOneLineString
@ -92,6 +93,7 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
@ -927,11 +929,12 @@ class MeshService : Service() {
Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
Timber.d("Received TRACEROUTE_APP from $fromId")
val routeDiscovery = packet.fullRouteDiscovery
val full = packet.getFullTracerouteResponse(::getUserName)
if (full != null) {
val requestId = packet.decoded.requestId
val start = tracerouteStartTimes.remove(requestId)
val response =
val responseText =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / 1000.0
@ -940,7 +943,19 @@ class MeshService : Service() {
} else {
full
}
serviceRepository.setTracerouteResponse(response)
val destination =
routeDiscovery?.routeList?.firstOrNull()
?: routeDiscovery?.routeBackList?.lastOrNull()
?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.routeList.orEmpty(),
returnRoute = routeDiscovery?.routeBackList.orEmpty(),
),
)
}
}

View file

@ -106,9 +106,11 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.toMessageRes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@ -117,6 +119,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
@ -133,6 +136,7 @@ import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SimpleAlertDialog
@ -239,16 +243,49 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
traceRouteResponse?.let { response ->
var tracerouteMapError by remember { mutableStateOf<StringResource?>(null) }
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
traceRouteResponse
?.takeIf { it.requestId != dismissedTracerouteRequestId }
?.let { response ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response.message))
}
},
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val availability =
uIViewModel.tracerouteMapAvailability(
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
val errorRes = availability.toMessageRes()
if (errorRes == null) {
dismissedTracerouteRequestId = response.requestId
navController.navigate(
NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId),
)
} else {
tracerouteMapError = errorRes
uIViewModel.clearTracerouteResponse()
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = {
uIViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
tracerouteMapError?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response))
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = { uIViewModel.clearTracerouteResponse() },
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = { tracerouteMapError = null },
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())