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 f53bde640..10c9cbde4 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -47,6 +47,7 @@ import com.geeksville.mesh.repository.api.FirmwareReleaseRepository import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.service.ServiceAction import com.geeksville.mesh.ui.map.MAP_STYLE_ID +import com.geeksville.mesh.Portnums import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -88,6 +89,7 @@ data class MetricsState( val isLocalDevice: Boolean = false, val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), + val paxMetrics: List = emptyList(), ) { fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() fun hasSignalMetrics() = signalMetrics.isNotEmpty() @@ -95,6 +97,7 @@ data class MetricsState( fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() fun hasPositionLogs() = positionLogs.isNotEmpty() fun hasHostMetrics() = hostMetrics.isNotEmpty() + fun hasPaxMetrics() = paxMetrics.isNotEmpty() fun deviceMetricsFiltered(timeFrame: TimeFrame): List { val oldestTime = timeFrame.calculateOldestTime() @@ -264,7 +267,7 @@ class MetricsViewModel @Inject constructor( val timeFrame: StateFlow = _timeFrame init { - destNum?.let { + if (destNum != null) { radioConfigRepository.nodeDBbyNum .mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() } .distinctUntilChanged() @@ -344,6 +347,12 @@ class MetricsViewModel @Inject constructor( } }.launchIn(viewModelScope) + meshLogRepository.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE).onEach { logs -> + _state.update { state -> + state.copy(paxMetrics = logs) + } + }.launchIn(viewModelScope) + firmwareReleaseRepository.stableRelease.filterNotNull().onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) @@ -357,6 +366,8 @@ class MetricsViewModel @Inject constructor( }.launchIn(viewModelScope) debug("MetricsViewModel created") + } else { + debug("MetricsViewModel: destNum is null, skipping metrics flows initialization.") } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt index 02eb2e06d..f1fca4905 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesRoutes.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.PermScanWifi +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Router import androidx.compose.runtime.remember @@ -42,6 +43,7 @@ import com.geeksville.mesh.ui.metrics.PositionLogScreen import com.geeksville.mesh.ui.metrics.PowerMetricsScreen import com.geeksville.mesh.ui.metrics.SignalMetricsScreen import com.geeksville.mesh.ui.metrics.TracerouteLogScreen +import com.geeksville.mesh.ui.metrics.PaxMetricsScreen import com.geeksville.mesh.ui.node.NodeDetailScreen import com.geeksville.mesh.ui.node.NodeMapScreen import com.geeksville.mesh.ui.node.NodeScreen @@ -86,6 +88,9 @@ sealed class NodeDetailRoutes { @Serializable data object HostMetricsLog : Route + + @Serializable + data object PaxMetrics : Route } fun NavGraphBuilder.nodesGraph( @@ -161,6 +166,7 @@ fun NavGraphBuilder.nodeDetailGraph( NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry)) NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry)) + NodeDetailRoute.PAX -> PaxMetricsScreen(hiltViewModel(parentEntry)) } } } @@ -180,4 +186,5 @@ enum class NodeDetailRoute( TRACEROUTE(R.string.traceroute, NodeDetailRoutes.TracerouteLog, Icons.Default.PermScanWifi), POWER(R.string.power, NodeDetailRoutes.PowerMetrics, Icons.Default.Power), HOST(R.string.host, NodeDetailRoutes.HostMetricsLog, Icons.Default.Memory), + PAX(R.string.pax, NodeDetailRoutes.PaxMetrics, Icons.Default.People), } diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt index fcefc1688..ca9010691 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt @@ -795,6 +795,7 @@ private fun DecodedPayloadBlock( isSelected: Boolean, colorScheme: ColorScheme ) { + val commonTextStyle = TextStyle( fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt new file mode 100644 index 000000000..6a6bea820 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.metrics + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.util.formatUptime +import com.geeksville.mesh.Portnums.PortNum +import java.text.DateFormat +import java.util.Date +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.remember +import com.geeksville.mesh.model.TimeFrame +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import com.geeksville.mesh.ui.common.components.OptionLabel +import com.geeksville.mesh.ui.common.components.SlidingSelector +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer + +private const val CHART_WEIGHT = 1f +private const val Y_AXIS_WEIGHT = 0.1f +private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT) + +private enum class PaxSeries(val color: Color, val legendRes: Int) { + PAX(Color.Black, R.string.pax), + BLE(Color.Cyan, R.string.ble_devices), + WIFI(Color.Green, R.string.wifi_devices) +} + +@Suppress("LongMethod") +@Composable +private fun PaxMetricsChart( + modifier: Modifier = Modifier, + totalSeries: List>, + bleSeries: List>, + wifiSeries: List>, + minValue: Float, + maxValue: Float, + timeFrame: TimeFrame, +) { + if (totalSeries.isEmpty()) return + val scrollState = rememberScrollState() + val screenWidth = LocalWindowInfo.current.containerSize.width + val times = totalSeries.map { it.first } + val minTime = times.minOrNull() ?: 0 + val maxTime = times.maxOrNull() ?: 1 + val timeDiff = maxTime - minTime + val dp = remember(timeFrame, screenWidth, timeDiff) { + timeFrame.dp(screenWidth, time = timeDiff.toLong()) + } + // Calculate visible time range based on scroll position and chart width + val visibleTimeRange = run { + val totalWidthPx = with(LocalDensity.current) { dp.toPx() } + val scrollPx = scrollState.value.toFloat() + val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO + val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) + val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) + val visibleOldest = minTime + (timeDiff * leftRatio).toInt() + val visibleNewest = minTime + (timeDiff * rightRatio).toInt() + visibleOldest to visibleNewest + } + TimeLabels( + oldest = visibleTimeRange.first, + newest = visibleTimeRange.second + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f) + ) { + YAxisLabels( + modifier = Modifier + .weight(Y_AXIS_WEIGHT) + .fillMaxHeight() + .padding(start = 8.dp), + + labelColor = MaterialTheme.colorScheme.onSurface, + minValue = minValue, + maxValue = maxValue + ) + Box( + contentAlignment = Alignment.TopStart, + modifier = Modifier + .horizontalScroll(state = scrollState, reverseScrolling = true) + .weight(CHART_WEIGHT) + ) { + HorizontalLinesOverlay( + modifier.width(dp), + lineColors = List(size = 5) { Color.LightGray }, + ) + TimeAxisOverlay( + modifier.width(dp), + oldest = minTime, + newest = maxTime, + timeFrame.lineInterval() + ) + Canvas(modifier = Modifier.width(dp).fillMaxHeight()) { + val width = size.width + val height = size.height + fun xForTime(t: Int): Float = + if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width + fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height + fun drawLine(series: List>, color: Color) { + for (i in 1 until series.size) { + drawLine( + color = color, + start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)), + end = Offset(xForTime(series[i].first), yForValue(series[i].second)), + strokeWidth = 2.dp.toPx() + ) + } + } + drawLine(bleSeries, PaxSeries.BLE.color) + drawLine(wifiSeries, PaxSeries.WIFI.color) + drawLine(totalSeries, PaxSeries.PAX.color) + } + } + YAxisLabels( + modifier = Modifier + .weight(Y_AXIS_WEIGHT) + .fillMaxHeight() + .padding(end = 8.dp), + labelColor = MaterialTheme.colorScheme.onSurface, + minValue = minValue, + maxValue = maxValue + ) + } + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +@Suppress("MagicNumber", "LongMethod") +fun PaxMetricsScreen( + metricsViewModel: MetricsViewModel = hiltViewModel(), +) { + val state by metricsViewModel.state.collectAsStateWithLifecycle() + val dateFormat = DateFormat.getDateTimeInstance() + var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } + // Only show logs that can be decoded as PaxcountProtos.Paxcount + val paxMetrics = state.paxMetrics.mapNotNull { log -> + val pax = decodePaxFromLog(log) + if (pax != null) { + Pair(log, pax) + } else { + null + } + } + // Prepare data for graph + val oldestTime = timeFrame.calculateOldestTime() + val graphData = paxMetrics.filter { it.first.received_date / 1000 >= oldestTime } + .map { + val t = (it.first.received_date / 1000).toInt() + Triple(t, it.second.ble, it.second.wifi) + } + .sortedBy { it.first } + val totalSeries = graphData.map { it.first to (it.second + it.third) } + val bleSeries = graphData.map { it.first to it.second } + val wifiSeries = graphData.map { it.first to it.third } + val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f) + val minValue = 0f + val legendData = listOf( + LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color), + LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color), + LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color), + ) + + Column(modifier = Modifier.fillMaxSize()) { + // Time frame selector + SlidingSelector( + options = TimeFrame.entries.toList(), + selectedOption = timeFrame, + onOptionSelected = { timeFrame = it } + ) { tf: TimeFrame -> + OptionLabel(stringResource(tf.strRes)) + } + // Graph + if (graphData.isNotEmpty()) { + ChartHeader(graphData.size) + Legend(legendData = legendData) + PaxMetricsChart( + totalSeries = totalSeries, + bleSeries = bleSeries, + wifiSeries = wifiSeries, + minValue = minValue, + maxValue = maxValue, + timeFrame = timeFrame + ) + } + // List + if (paxMetrics.isEmpty()) { + Text( + text = stringResource(R.string.no_pax_metrics_logs), + modifier = Modifier.fillMaxSize().padding(16.dp), + textAlign = TextAlign.Center + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(paxMetrics) { (log, pax) -> + PaxMetricsItem(log, pax, dateFormat) + } + } + } + } +} + +@Suppress("MagicNumber", "CyclomaticComplexMethod") +fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? { + var result: PaxcountProtos.Paxcount? = null + // First, try to parse from the binary fromRadio field (robust, like telemetry) + try { + val packet = log.fromRadio.packet + if (packet != null && packet.hasDecoded() && + packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) { + val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload) + if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax + } + } catch (e: com.google.protobuf.InvalidProtocolBufferException) { + android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e) + } catch (e: IllegalArgumentException) { + android.util.Log.e("PaxMetrics", "Invalid argument while parsing Paxcount from binary data", e) + } + // Fallback: Try direct base64 or bytes from raw_message + if (result == null) { + try { + val base64 = log.raw_message.trim() + if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) { + val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + val pax = PaxcountProtos.Paxcount.parseFrom(bytes) + result = pax + } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { + val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val pax = PaxcountProtos.Paxcount.parseFrom(bytes) + result = pax + } + } catch (e: IllegalArgumentException) { + android.util.Log.e("PaxMetrics", "Invalid Base64 or hex input", e) + } catch (e: com.google.protobuf.InvalidProtocolBufferException) { + android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e) + } + } + return result +} + +@Suppress("MagicNumber") +fun unescapeProtoString(escaped: String): ByteArray { + val out = mutableListOf() + var i = 0 + while (i < escaped.length) { + if (escaped[i] == '\\' && i + 3 < escaped.length && escaped[i + 1].isDigit()) { + // Octal escape: \\ddd + val octal = escaped.substring(i + 1, i + 4) + out.add(octal.toInt(8).toByte()) + i += 4 + } else { + out.add(escaped[i].code.toByte()) + i++ + } + } + return out.toByteArray() +} + +@Composable +fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = dateFormat.format(Date(log.received_date)), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth() + ) + val total = pax.ble + pax.wifi + val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})" + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = summary, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f, fill = true) + ) + Text( + text = stringResource(R.string.uptime) + ": " + formatUptime(pax.uptime), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + modifier = Modifier.alignByBaseline() + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt index 0d326a58e..a159d6019 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt @@ -53,6 +53,7 @@ import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Route @@ -175,6 +176,7 @@ private enum class LogsType( POWER(R.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics), TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog), HOST(R.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog), + PAX(R.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics), } @Suppress("LongMethod") @@ -202,6 +204,7 @@ fun NodeDetailScreen( state.hasPowerMetrics(), state.hasTracerouteLogs(), state.hasHostMetrics(), + state.hasPaxMetrics(), // Added for PAX log ) } val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70152ab24..5d1ca72ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -790,6 +790,11 @@ Clear selection Message Type a message + PAX Metrics Log + PAX + No PAX metrics logs available. + WiFi Devices + BLE Devices Rate Limit Exceeded. Please try again later.