kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
feat: traceroute log (#1348)
rodzic
a3b4b70db9
commit
a557bff3d7
|
@ -39,6 +39,14 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L
|
||||||
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
|
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
|
||||||
.flowOn(Dispatchers.IO)
|
.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'.
|
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
|
||||||
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
|
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
|
||||||
|
@ -47,8 +55,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L
|
||||||
fun getMeshPacketsFrom(
|
fun getMeshPacketsFrom(
|
||||||
nodeNum: Int,
|
nodeNum: Int,
|
||||||
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
|
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
|
||||||
): Flow<List<MeshPacket>> = meshLogDao.getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS)
|
): Flow<List<MeshPacket>> = getLogsFrom(nodeNum, portNum)
|
||||||
.distinctUntilChanged()
|
|
||||||
.mapLatest { list -> list.map { it.fromRadio.packet } }
|
.mapLatest { list -> list.map { it.fromRadio.packet } }
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,10 @@ package com.geeksville.mesh.model
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.geeksville.mesh.MeshProtos.MeshPacket
|
import com.geeksville.mesh.MeshProtos.MeshPacket
|
||||||
|
import com.geeksville.mesh.Portnums.PortNum
|
||||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||||
import com.geeksville.mesh.database.MeshLogRepository
|
import com.geeksville.mesh.database.MeshLogRepository
|
||||||
|
import com.geeksville.mesh.database.entity.MeshLog
|
||||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
@ -16,9 +18,11 @@ import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class MetricsState(
|
data class MetricsState(
|
||||||
|
val isManaged: Boolean = true,
|
||||||
val deviceMetrics: List<Telemetry> = emptyList(),
|
val deviceMetrics: List<Telemetry> = emptyList(),
|
||||||
val environmentMetrics: List<Telemetry> = emptyList(),
|
val environmentMetrics: List<Telemetry> = emptyList(),
|
||||||
val signalMetrics: List<MeshPacket> = emptyList(),
|
val signalMetrics: List<MeshPacket> = emptyList(),
|
||||||
|
val hasTracerouteLogs: Boolean = false,
|
||||||
val environmentDisplayFahrenheit: Boolean = false,
|
val environmentDisplayFahrenheit: Boolean = false,
|
||||||
) {
|
) {
|
||||||
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
|
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
|
||||||
|
@ -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
|
@HiltViewModel
|
||||||
class MetricsViewModel @Inject constructor(
|
class MetricsViewModel @Inject constructor(
|
||||||
meshLogRepository: MeshLogRepository,
|
meshLogRepository: MeshLogRepository,
|
||||||
radioConfigRepository: RadioConfigRepository,
|
private val radioConfigRepository: RadioConfigRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val destNum = MutableStateFlow(0)
|
private val destNum = MutableStateFlow(0)
|
||||||
|
|
||||||
private fun MeshPacket.hasValidSignal(): Boolean =
|
private fun MeshPacket.hasValidSignal(): Boolean =
|
||||||
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state = destNum.flatMapLatest { destNum ->
|
val state = destNum.flatMapLatest { destNum ->
|
||||||
combine(
|
combine(
|
||||||
meshLogRepository.getTelemetryFrom(destNum),
|
meshLogRepository.getTelemetryFrom(destNum),
|
||||||
meshLogRepository.getMeshPacketsFrom(destNum),
|
meshLogRepository.getMeshPacketsFrom(destNum),
|
||||||
radioConfigRepository.moduleConfigFlow,
|
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||||
) { telemetry, meshPackets, config ->
|
radioConfigRepository.deviceProfileFlow,
|
||||||
|
) { telemetry, meshPackets, traceroute, profile ->
|
||||||
|
val moduleConfig = profile.moduleConfig
|
||||||
MetricsState(
|
MetricsState(
|
||||||
|
isManaged = profile.config.security.isManaged,
|
||||||
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
||||||
environmentMetrics = telemetry.filter {
|
environmentMetrics = telemetry.filter {
|
||||||
it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f
|
it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f
|
||||||
},
|
},
|
||||||
signalMetrics = meshPackets.filter { it.hasValidSignal() },
|
signalMetrics = meshPackets.filter { it.hasValidSignal() },
|
||||||
environmentDisplayFahrenheit = config.telemetry.environmentDisplayFahrenheit,
|
hasTracerouteLogs = traceroute.any { it.hasValidTraceroute() },
|
||||||
|
environmentDisplayFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = WhileSubscribed(),
|
started = WhileSubscribed(stopTimeoutMillis = 5000L),
|
||||||
initialValue = MetricsState.Empty,
|
initialValue = MetricsState.Empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -66,6 +66,7 @@ import com.geeksville.mesh.service.MeshService.ConnectionState
|
||||||
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
|
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
|
||||||
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
|
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
|
||||||
import com.geeksville.mesh.ui.components.SignalMetricsScreen
|
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.AmbientLightingConfigItemList
|
||||||
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
|
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
|
||||||
import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList
|
import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList
|
||||||
|
@ -297,6 +298,9 @@ fun NavGraph(
|
||||||
metricsState.environmentDisplayFahrenheit,
|
metricsState.environmentDisplayFahrenheit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("TracerouteList") {
|
||||||
|
TracerouteLogScreen(metricsViewModel)
|
||||||
|
}
|
||||||
composable("SignalMetrics") {
|
composable("SignalMetrics") {
|
||||||
SignalMetricsScreen(metricsState.signalMetrics)
|
SignalMetricsScreen(metricsState.signalMetrics)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,8 @@ import androidx.compose.material.icons.filled.KeyOff
|
||||||
import androidx.compose.material.icons.filled.Numbers
|
import androidx.compose.material.icons.filled.Numbers
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Power
|
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.Settings
|
||||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||||
import androidx.compose.material.icons.filled.Speed
|
import androidx.compose.material.icons.filled.Speed
|
||||||
|
@ -93,6 +95,7 @@ fun NodeDetailsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
private fun NodeDetailsItemList(
|
private fun NodeDetailsItemList(
|
||||||
node: NodeEntity,
|
node: NodeEntity,
|
||||||
|
@ -151,10 +154,18 @@ private fun NodeDetailsItemList(
|
||||||
onNavigate("SignalMetrics")
|
onNavigate("SignalMetrics")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavCard(
|
||||||
|
title = stringResource(R.string.traceroute_logs),
|
||||||
|
icon = Icons.Default.Route,
|
||||||
|
enabled = metricsState.hasTracerouteLogs
|
||||||
|
) {
|
||||||
|
onNavigate("TracerouteList")
|
||||||
|
}
|
||||||
|
|
||||||
NavCard(
|
NavCard(
|
||||||
title = "Remote Administration",
|
title = "Remote Administration",
|
||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.Settings,
|
||||||
enabled = !node.user.isLicensed // TODO check for isManaged
|
enabled = !metricsState.isManaged || !node.user.isLicensed
|
||||||
) {
|
) {
|
||||||
onNavigate("RadioConfig")
|
onNavigate("RadioConfig")
|
||||||
}
|
}
|
||||||
|
@ -219,6 +230,11 @@ private fun NodeDetailsContent(node: NodeEntity) {
|
||||||
icon = Icons.Default.Work,
|
icon = Icons.Default.Work,
|
||||||
value = node.user.role.name
|
value = node.user.role.name
|
||||||
)
|
)
|
||||||
|
NodeDetailRow(
|
||||||
|
label = "Hardware",
|
||||||
|
icon = Icons.Default.Router,
|
||||||
|
value = node.user.hwModel.name
|
||||||
|
)
|
||||||
if (node.deviceMetrics.uptimeSeconds > 0) {
|
if (node.deviceMetrics.uptimeSeconds > 0) {
|
||||||
NodeDetailRow(
|
NodeDetailRow(
|
||||||
label = "Uptime",
|
label = "Uptime",
|
||||||
|
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -287,4 +287,11 @@
|
||||||
<string name="none_quality">None</string>
|
<string name="none_quality">None</string>
|
||||||
<string name="signal">Signal</string>
|
<string name="signal">Signal</string>
|
||||||
<string name="signal_quality">Signal Quality</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>
|
</resources>
|
||||||
|
|
Ładowanie…
Reference in New Issue