feat: position logs

pull/1368/head
andrekir 2024-11-02 13:23:04 -03:00 zatwierdzone przez Andre K
rodzic 26f210047d
commit adbe5952fc
8 zmienionych plików z 392 dodań i 12 usunięć

Wyświetl plik

@ -71,6 +71,10 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L
meshLogDao.deleteLog(uuid)
}
suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(Dispatchers.IO) {
meshLogDao.deleteLogs(nodeNum, portNum)
}
companion object {
private const val MAX_ITEMS = 500
private const val MAX_MESH_PACKETS = 10000

Wyświetl plik

@ -36,4 +36,7 @@ interface MeshLogDao {
@Query("DELETE FROM log WHERE uuid = :uuid")
fun deleteLog(uuid: String)
@Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum")
fun deleteLogs(fromNum: Int, portNum: Int)
}

Wyświetl plik

@ -1,9 +1,13 @@
package com.geeksville.mesh.model
import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.android.Logging
@ -20,38 +24,54 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
data class MetricsState(
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
val deviceMetrics: List<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshPacket> = emptyList(),
val positionLogs: List<Position> = emptyList(),
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
fun hasPositionLogs() = positionLogs.isNotEmpty()
companion object {
val Empty = MetricsState()
}
}
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
runCatching { Position.parseFrom(decoded.payload) }.getOrNull()
} else {
null
}
@HiltViewModel
class MetricsViewModel @Inject constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
) : ViewModel(), Logging {
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
}
@ -62,6 +82,10 @@ class MetricsViewModel @Inject constructor(
meshLogRepository.deleteLog(uuid)
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
meshLogRepository.deleteLogs(destNum.value, PortNum.POSITION_APP_VALUE)
}
private val _state = MutableStateFlow(MetricsState.Empty)
val state: StateFlow<MetricsState> = _state
@ -114,6 +138,15 @@ class MetricsViewModel @Inject constructor(
}
}.launchIn(viewModelScope)
@OptIn(ExperimentalCoroutinesApi::class)
destNum.flatMapLatest { destNum ->
meshLogRepository.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE).onEach { packets ->
_state.update { state ->
state.copy(positionLogs = packets.mapNotNull { it.toPosition() })
}
}
}.launchIn(viewModelScope)
debug("MetricsViewModel created")
}
@ -128,4 +161,45 @@ class MetricsViewModel @Inject constructor(
fun setSelectedNode(nodeNum: Int) {
destNum.value = nodeNum
}
/**
* Write the persisted Position data out to a CSV file in the specified location.
*/
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"")
val dateFormat =
SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
val rxDateTime = dateFormat.format(position.time * 1000L)
val latitude = position.latitudeI * 1e-7
val longitude = position.longitudeI * 1e-7
val altitude = position.altitude
val satsInView = position.satsInView
val speed = position.groundSpeed
val heading = "%.2f".format(position.groundTrack * 1e-5)
// date,time,latitude,longitude,altitude,satsInView,speed,heading
writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"")
}
}
}
private suspend inline fun writeToUri(
uri: Uri,
crossinline block: suspend (BufferedWriter) -> Unit
) = withContext(dispatchers.io) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
errormsg("Can't write file error: ${ex.message}")
}
}
}

Wyświetl plik

@ -61,6 +61,7 @@ import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.RadioConfigViewModel
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.components.PositionLogScreen
import com.geeksville.mesh.ui.components.SignalMetricsScreen
import com.geeksville.mesh.ui.components.TracerouteLogScreen
import com.geeksville.mesh.ui.components.config.AmbientLightingConfigScreen
@ -250,6 +251,10 @@ fun NavGraph(
val parentEntry = remember { navController.getBackStackEntry("NodeDetails") }
DeviceMetricsScreen(hiltViewModel<MetricsViewModel>(parentEntry))
}
composable("PositionLog") {
val parentEntry = remember { navController.getBackStackEntry("NodeDetails") }
PositionLogScreen(hiltViewModel<MetricsViewModel>(parentEntry))
}
composable("EnvironmentMetrics") {
val parentEntry = remember { navController.getBackStackEntry("NodeDetails") }
EnvironmentMetricsScreen(hiltViewModel<MetricsViewModel>(parentEntry))

Wyświetl plik

@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.ChargingStation
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
@ -135,7 +136,7 @@ private fun NodeDetailList(
item {
NavCard(
title = stringResource(R.string.device_metrics_logs),
title = stringResource(R.string.device_metrics_log),
icon = Icons.Default.ChargingStation,
enabled = metricsState.hasDeviceMetrics()
) {
@ -143,7 +144,13 @@ private fun NodeDetailList(
}
NavCard(
title = stringResource(R.string.env_metrics_logs),
title = stringResource(R.string.position_log),
icon = Icons.Default.LocationOn,
enabled = metricsState.hasPositionLogs()
) { onNavigate("PositionLog") }
NavCard(
title = stringResource(R.string.env_metrics_log),
icon = Icons.Default.Thermostat,
enabled = metricsState.hasEnvironmentMetrics()
) {
@ -151,7 +158,7 @@ private fun NodeDetailList(
}
NavCard(
title = stringResource(R.string.sig_metrics_logs),
title = stringResource(R.string.sig_metrics_log),
icon = Icons.Default.SignalCellularAlt,
enabled = metricsState.hasSignalMetrics()
) {
@ -159,7 +166,7 @@ private fun NodeDetailList(
}
NavCard(
title = stringResource(R.string.traceroute_logs),
title = stringResource(R.string.traceroute_log),
icon = Icons.Default.Route,
enabled = metricsState.hasTracerouteLogs()
) {

Wyświetl plik

@ -0,0 +1,280 @@
package com.geeksville.mesh.ui.components
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.metersIn
import com.geeksville.mesh.util.toString
import java.text.DateFormat
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
Text(
text = text,
modifier = Modifier.weight(weight),
fontSize = MaterialTheme.typography.caption.fontSize,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
private const val Weight10 = .10f
private const val Weight15 = .15f
private const val Weight20 = .20f
private const val Weight35 = .35f
@Composable
private fun HeaderItem(compactWidth: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("Latitude", Weight20)
PositionText("Longitude", Weight20)
PositionText("Sats", Weight10)
PositionText("Alt", Weight15)
if (!compactWidth) {
PositionText("Speed", Weight10)
PositionText("Heading", Weight10)
}
PositionText("Timestamp", Weight35)
}
}
private const val DegD = 1e-7
private const val HeadingDeg = 1e-5
private const val SecondsToMillis = 1000L
@Composable
private fun PositionItem(
compactWidth: Boolean,
position: MeshProtos.Position,
dateFormat: DateFormat,
system: DisplayUnits,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format(position.latitudeI * DegD), Weight20)
PositionText("%.5f".format(position.longitudeI * DegD), Weight20)
PositionText(position.satsInView.toString(), Weight10)
PositionText(position.altitude.metersIn(system).toString(system), Weight15)
if (!compactWidth) {
PositionText("${position.groundSpeed} Km/h", Weight10)
PositionText("%.0f°".format(position.groundTrack * HeadingDeg), Weight10)
}
PositionText(dateFormat.format(position.time * SecondsToMillis), Weight35)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
onClear: () -> Unit,
saveButtonEnabled: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.error,
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.clear),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.clear),
)
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onSave,
enabled = saveButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium),
)
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(id = R.string.save),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.save),
)
}
}
}
@Composable
fun PositionLogScreen(
viewModel: MetricsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
}
}
var clearButtonEnabled by rememberSaveable(state.positionLogs) {
mutableStateOf(state.positionLogs.isNotEmpty())
}
BoxWithConstraints {
val compactWidth = maxWidth < 600.dp
Column {
HeaderItem(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "position.csv")
}
exportPositionLauncher.launch(intent)
},
)
}
}
}
@Composable
private fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<MeshProtos.Position>,
displayUnits: DisplayUnits,
) {
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
LazyColumn(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(positions) { position ->
PositionItem(compactWidth, position, dateFormat, displayUnits)
}
}
}
}
@Suppress("MagicNumber")
private val testPosition = MeshProtos.Position.newBuilder().apply {
latitudeI = 297604270
longitudeI = -953698040
altitude = 1230
satsInView = 7
time = (System.currentTimeMillis() / 1000).toInt()
}.build()
@Preview(showBackground = true)
@Composable
private fun PositionItemPreview() {
AppTheme {
PositionItem(
compactWidth = false,
position = testPosition,
dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
system = DisplayUnits.METRIC,
)
}
}
@PreviewScreenSizes
@Composable
private fun ActionButtonsPreview() {
AppTheme {
Column(Modifier.fillMaxSize(), Arrangement.Bottom) {
ActionButtons(
clearButtonEnabled = true,
onClear = {},
saveButtonEnabled = true,
onSave = {},
)
}
}
}

Wyświetl plik

@ -272,16 +272,17 @@
<string name="rssi">RSSI</string>
<string name="rssi_definition">Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.</string>
<string name="iaq_definition">(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500.</string>
<string name="device_metrics_logs">Device Metrics Logs</string>
<string name="env_metrics_logs">Environment Metrics Logs</string>
<string name="sig_metrics_logs">Signal Metrics Logs</string>
<string name="device_metrics_log">Device Metrics Log</string>
<string name="position_log">Position Log</string>
<string name="env_metrics_log">Environment Metrics Log</string>
<string name="sig_metrics_log">Signal Metrics Log</string>
<string name="bad">Bad</string>
<string name="fair">Fair</string>
<string name="good">Good</string>
<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_log">Traceroute Log</string>
<string name="traceroute_direct">Direct</string>
<plurals name="traceroute_hops">
<item quantity="one">1 hop</item>

Wyświetl plik

@ -1,6 +1,12 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<ManuallySuppressedIssues>
<ID>MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"")</ID>
<ID>MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"")</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
</ManuallySuppressedIssues>
<CurrentIssues>
<ID>ChainWrapping:Channel.kt$Channel$&amp;&amp;</ID>
<ID>ChainWrapping:CustomTileSource.kt$CustomTileSource.Companion.&lt;no name provided&gt;$+</ID>