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

@ -17,6 +17,10 @@
package org.meshtastic.core.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.traceroute_endpoint_missing
import org.meshtastic.core.strings.traceroute_map_no_data
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.RouteDiscovery
import org.meshtastic.proto.Portnums
@ -28,11 +32,13 @@ val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery?
runCatching { RouteDiscovery.parseFrom(payload).toBuilder() }
.getOrNull()
?.apply {
val fullRoute = listOf(to) + routeList + from
val destinationId = dest.takeIf { it != 0 } ?: this@fullRouteDiscovery.to
val sourceId = source.takeIf { it != 0 } ?: this@fullRouteDiscovery.from
val fullRoute = listOf(destinationId) + routeList + sourceId
clearRoute()
addAllRoute(fullRoute)
val fullRouteBack = listOf(from) + routeBackList + to
val fullRouteBack = listOf(sourceId) + routeBackList + destinationId
clearRouteBack()
if (hopStart > 0 && snrBackCount > 0) { // otherwise back route is invalid
addAllRouteBack(fullRouteBack)
@ -85,3 +91,35 @@ fun MeshProtos.MeshPacket.getTracerouteResponse(getUser: (nodeNum: Int) -> Strin
fun MeshProtos.MeshPacket.getFullTracerouteResponse(getUser: (nodeNum: Int) -> String): String? = fullRouteDiscovery
?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() }
?.getTracerouteResponse(getUser)
enum class TracerouteMapAvailability {
Ok,
MissingEndpoints,
NoMappableNodes,
}
fun evaluateTracerouteMapAvailability(
forwardRoute: List<Int>,
returnRoute: List<Int>,
positionedNodeNums: Set<Int>,
): TracerouteMapAvailability {
val endpoints =
listOfNotNull(
forwardRoute.firstOrNull(),
forwardRoute.lastOrNull(),
returnRoute.firstOrNull(),
returnRoute.lastOrNull(),
)
.distinct()
val missingEndpoint = endpoints.any { !positionedNodeNums.contains(it) }
if (missingEndpoint) return TracerouteMapAvailability.MissingEndpoints
val relatedNodeNums = (forwardRoute + returnRoute).toSet()
val hasAnyMappable = relatedNodeNums.any { positionedNodeNums.contains(it) }
return if (hasAnyMappable) TracerouteMapAvailability.Ok else TracerouteMapAvailability.NoMappableNodes
}
fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) {
TracerouteMapAvailability.Ok -> null
TracerouteMapAvailability.MissingEndpoints -> Res.string.traceroute_endpoint_missing
TracerouteMapAvailability.NoMappableNodes -> Res.string.traceroute_map_no_data
}

View file

@ -78,6 +78,8 @@ object NodeDetailRoutes {
@Serializable data class TracerouteLog(val destNum: Int) : Route
@Serializable data class TracerouteMap(val destNum: Int, val requestId: Int) : Route
@Serializable data class HostMetricsLog(val destNum: Int) : Route
@Serializable data class PaxMetrics(val destNum: Int) : Route

View file

@ -29,6 +29,17 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
val hasOverlay: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
@ -94,11 +105,11 @@ class ServiceRepository @Inject constructor() {
_meshPacketFlow.emit(packet)
}
private val _tracerouteResponse = MutableStateFlow<String?>(null)
val tracerouteResponse: StateFlow<String?>
private val _tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
val tracerouteResponse: StateFlow<TracerouteResponse?>
get() = _tracerouteResponse
fun setTracerouteResponse(value: String?) {
fun setTracerouteResponse(value: TracerouteResponse?) {
_tracerouteResponse.value = value
}

View file

@ -405,6 +405,12 @@
<item quantity="other">%1$d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %1$d Hops back %2$d</string>
<string name="traceroute_outgoing_route">Outgoing route</string>
<string name="traceroute_return_route">Return route</string>
<string name="traceroute_endpoint_missing">Cannot show traceroute map because the start or destination node has no position information.</string>
<string name="view_on_map">View on map</string>
<string name="traceroute_map_no_data">This traceroute does not have any mappable nodes yet.</string>
<string name="traceroute_showing_nodes">Showing %1$d/%2$d nodes</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
<string name="one_week">1W</string>
@ -1030,8 +1036,8 @@
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<!-- Compass -->
<!-- Compass -->
<string name="compass_title">Compass</string>
<string name="open_compass">Open Compass</string>
<string name="compass_distance">Distance: %1$s</string>

View file

@ -27,6 +27,13 @@ val MeshtasticAlt = Color(0xFF2C2D3C)
val HyperlinkBlue = Color(0xFF43C3B0)
val AnnotationColor = Color(0xFF039BE5)
object TracerouteColors {
// High-contrast pair that stays legible on light/dark tiles and for most color-blind users.
// Use partial alpha so polylines dont overpower markers/tiles.
val OutgoingRoute = Color(0xCCE86A00) // orange @ ~80% opacity
val ReturnRoute = Color(0xCC0081C7) // cyan @ ~80% opacity
}
object IAQColors {
val IAQExcellent = Color(0xFF00E400)
val IAQGood = Color(0xFF92D050)