Skip to content

Commit

Permalink
feat: traceroute log (#1348)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrekir authored Oct 25, 2024
1 parent a3b4b70 commit a557bff
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.flowOn(Dispatchers.IO)

fun getLogsFrom(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
maxItem: Int = MAX_MESH_PACKETS,
): Flow<List<MeshLog>> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem)
.distinctUntilChanged()
.flowOn(Dispatchers.IO)

/*
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
Expand All @@ -47,8 +55,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L
fun getMeshPacketsFrom(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
): Flow<List<MeshPacket>> = meshLogDao.getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS)
.distinctUntilChanged()
): Flow<List<MeshPacket>> = getLogsFrom(nodeNum, portNum)
.mapLatest { list -> list.map { it.fromRadio.packet } }
.flowOn(Dispatchers.IO)

Expand Down
51 changes: 46 additions & 5 deletions app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package com.geeksville.mesh.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -16,9 +18,11 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject

data class MetricsState(
val isManaged: Boolean = true,
val deviceMetrics: List<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val hasTracerouteLogs: Boolean = false,
val environmentDisplayFahrenheit: Boolean = false,
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
Expand All @@ -30,35 +34,72 @@ data class MetricsState(
}
}

data class TracerouteLogState(
val requests: List<MeshLog> = emptyList(),
val results: List<MeshPacket> = emptyList(),
) {
companion object {
val Empty = TracerouteLogState()
}
}

@HiltViewModel
class MetricsViewModel @Inject constructor(
meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
private val radioConfigRepository: RadioConfigRepository,
) : ViewModel() {
private val destNum = MutableStateFlow(0)

private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)

private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == destNum.value
}

fun getUser(nodeNum: Int) = radioConfigRepository.getUser(nodeNum)

@OptIn(ExperimentalCoroutinesApi::class)
val tracerouteState = destNum.flatMapLatest { destNum ->
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getMeshPacketsFrom(destNum),
) { request, response ->
val test = request.filter { it.hasValidTraceroute() }
TracerouteLogState(
requests = test,
results = response,
)
}
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(stopTimeoutMillis = 5000L),
initialValue = TracerouteLogState.Empty,
)

@OptIn(ExperimentalCoroutinesApi::class)
val state = destNum.flatMapLatest { destNum ->
combine(
meshLogRepository.getTelemetryFrom(destNum),
meshLogRepository.getMeshPacketsFrom(destNum),
radioConfigRepository.moduleConfigFlow,
) { telemetry, meshPackets, config ->
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
radioConfigRepository.deviceProfileFlow,
) { telemetry, meshPackets, traceroute, profile ->
val moduleConfig = profile.moduleConfig
MetricsState(
isManaged = profile.config.security.isManaged,
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
environmentMetrics = telemetry.filter {
it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f
},
signalMetrics = meshPackets.filter { it.hasValidSignal() },
environmentDisplayFahrenheit = config.telemetry.environmentDisplayFahrenheit,
hasTracerouteLogs = traceroute.any { it.hasValidTraceroute() },
environmentDisplayFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
)
}
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(),
started = WhileSubscribed(stopTimeoutMillis = 5000L),
initialValue = MetricsState.Empty,
)

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.geeksville.mesh.service.MeshService.ConnectionState
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.components.SignalMetricsScreen
import com.geeksville.mesh.ui.components.TracerouteLogScreen
import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList
Expand Down Expand Up @@ -297,6 +298,9 @@ fun NavGraph(
metricsState.environmentDisplayFahrenheit,
)
}
composable("TracerouteList") {
TracerouteLogScreen(metricsViewModel)
}
composable("SignalMetrics") {
SignalMetricsScreen(metricsState.signalMetrics)
}
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Speed
Expand Down Expand Up @@ -93,6 +95,7 @@ fun NodeDetailsScreen(
}
}

@Suppress("LongMethod")
@Composable
private fun NodeDetailsItemList(
node: NodeEntity,
Expand Down Expand Up @@ -151,10 +154,18 @@ private fun NodeDetailsItemList(
onNavigate("SignalMetrics")
}

NavCard(
title = stringResource(R.string.traceroute_logs),
icon = Icons.Default.Route,
enabled = metricsState.hasTracerouteLogs
) {
onNavigate("TracerouteList")
}

NavCard(
title = "Remote Administration",
icon = Icons.Default.Settings,
enabled = !node.user.isLicensed // TODO check for isManaged
enabled = !metricsState.isManaged || !node.user.isLicensed
) {
onNavigate("RadioConfig")
}
Expand Down Expand Up @@ -219,6 +230,11 @@ private fun NodeDetailsContent(node: NodeEntity) {
icon = Icons.Default.Work,
value = node.user.role.name
)
NodeDetailRow(
label = "Hardware",
icon = Icons.Default.Router,
value = node.user.hwModel.name
)
if (node.deviceMetrics.uptimeSeconds > 0) {
NodeDetailRow(
label = "Uptime",
Expand Down
162 changes: 162 additions & 0 deletions app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.geeksville.mesh.ui.components

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.getTracerouteResponse
import com.geeksville.mesh.ui.theme.AppTheme
import java.text.DateFormat

@Composable
fun TracerouteLogScreen(
viewModel: MetricsViewModel = hiltViewModel(),
modifier: Modifier = Modifier,
) {
val state by viewModel.tracerouteState.collectAsStateWithLifecycle()
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}

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

var showDialog by remember { mutableStateOf<String?>(null) }

if (showDialog != null) {
val message = showDialog ?: return
SimpleAlertDialog(
title = R.string.traceroute,
text = {
SelectionContainer {
Text(text = message)
}
},
onDismiss = { showDialog = null }
)
}

LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.requests, key = { it.uuid }) { log ->
val result = remember(state.requests) {
state.results.find { it.decoded.requestId == log.fromRadio.packet.id }
}
val route = remember(result) {
result?.let { MeshProtos.RouteDiscovery.parseFrom(it.decoded.payload) }
}

val time = dateFormat.format(log.received_date)
val (text, icon) = route.getTextAndIcon()

TracerouteItem(
icon = icon,
text = "$time - $text",
modifier = Modifier.clickable(enabled = result != null) {
if (result != null) {
showDialog = result.getTracerouteResponse(::getUsername)
}
}
)
}
}
}

@Composable
private fun TracerouteItem(
icon: ImageVector,
text: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(vertical = 2.dp),
elevation = 4.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = stringResource(id = R.string.traceroute)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = MaterialTheme.typography.body1,
)
}
}
}

@Composable
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
this == null -> {
stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff
}

routeList.isEmpty() -> {
stringResource(R.string.traceroute_direct) to Icons.Default.Group
}

routeList.size == routeBackList.size -> {
val hops = routeList.size
pluralStringResource(R.plurals.traceroute_hops, hops, hops) to Icons.Default.Groups
}

else -> {
val (towards, back) = routeList.size to routeBackList.size
stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups
}
}

@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
AppTheme {
TracerouteItem(
icon = Icons.Default.Group,
text = "${dateFormat.format(System.currentTimeMillis())} - Direct"
)
}
}
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,11 @@
<string name="none_quality">None</string>
<string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string>
<string name="traceroute_logs">Traceroute Logs</string>
<string name="traceroute_direct">Direct</string>
<plurals name="traceroute_hops">
<item quantity="one">1 hop</item>
<item quantity="other">%d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %d Hops back %d</string>
</resources>

0 comments on commit a557bff

Please sign in to comment.