From adbe5952fc13ccd95461172e27011d64aee05ce6 Mon Sep 17 00:00:00 2001 From: andrekir Date: Sat, 2 Nov 2024 13:23:04 -0300 Subject: [PATCH] feat: position logs --- .../mesh/database/MeshLogRepository.kt | 4 + .../mesh/database/dao/MeshLogDao.kt | 3 + .../geeksville/mesh/model/MetricsViewModel.kt | 80 ++++- .../java/com/geeksville/mesh/ui/NavGraph.kt | 5 + .../java/com/geeksville/mesh/ui/NodeDetail.kt | 15 +- .../mesh/ui/components/PositionLog.kt | 280 ++++++++++++++++++ app/src/main/res/values/strings.xml | 9 +- config/detekt/detekt-baseline.xml | 8 +- 8 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 805b7d68e..82c77eeea 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt index d5a77fa1d..f11227ff1 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt @@ -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) } diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index 6785379cc..3dceafae1 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -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 = emptyList(), val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), + val positionLogs: List = 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 = _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}") + } + } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 1547241ee..fb7d761b1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -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(parentEntry)) } + composable("PositionLog") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + PositionLogScreen(hiltViewModel(parentEntry)) + } composable("EnvironmentMetrics") { val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } EnvironmentMetricsScreen(hiltViewModel(parentEntry)) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index a8c5a7cbd..7bb1e3afa 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -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() ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt new file mode 100644 index 000000000..372152777 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt @@ -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, + 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 = {}, + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe5bbf6b3..1d73636bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,16 +272,17 @@ RSSI 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. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. - Device Metrics Logs - Environment Metrics Logs - Signal Metrics Logs + Device Metrics Log + Position Log + Environment Metrics Log + Signal Metrics Log Bad Fair Good None Signal Signal Quality - Traceroute Logs + Traceroute Log Direct 1 hop diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml index 98a7c0212..87b78081a 100644 --- a/config/detekt/detekt-baseline.xml +++ b/config/detekt/detekt-baseline.xml @@ -1,6 +1,12 @@ - + + MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"") + MaxLineLength:MetricsViewModel.kt$MetricsViewModel$writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"") + MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L + MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7 + MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5 + ChainWrapping:Channel.kt$Channel$&& ChainWrapping:CustomTileSource.kt$CustomTileSource.Companion.<no name provided>$+