Feat/1919 pax graphs (#2477)

Signed-off-by: DaneEvans <dane@goneepic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
DaneEvans
2025-07-21 22:44:29 +10:00
committed by GitHub
parent d6354f7d0f
commit ee99d79574
7 changed files with 507 additions and 12 deletions

View File

@@ -42,6 +42,12 @@ import com.geeksville.mesh.Portnums.PortNum
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.google.protobuf.InvalidProtocolBufferException
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.StoreAndForwardProtos
data class SearchMatch(
val logIndex: Int,
@@ -156,6 +162,7 @@ class LogFilterManager {
}
}
@Suppress("TooManyFunctions")
@HiltViewModel
class DebugViewModel @Inject constructor(
private val meshLogRepository: MeshLogRepository,
@@ -206,6 +213,7 @@ class DebugViewModel @Inject constructor(
messageType = log.message_type,
formattedReceivedDate = TIME_FORMAT.format(log.received_date),
logMessage = annotateMeshLogMessage(log),
decodedPayload = decodePayloadFromMeshLog(log),
)
}.toImmutableList()
@@ -213,22 +221,33 @@ class DebugViewModel @Inject constructor(
* Transform the input [MeshLog] by enhancing the raw message with annotations.
*/
private fun annotateMeshLogMessage(meshLog: MeshLog): String {
val annotated = when (meshLog.message_type) {
return when (meshLog.message_type) {
"Packet" -> meshLog.meshPacket?.let { packet ->
annotateRawMessage(meshLog.raw_message, packet.from, packet.to)
}
annotatePacketLog(packet)
} ?: meshLog.raw_message
"NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.num)
}
} ?: meshLog.raw_message
"MyNodeInfo" -> meshLog.myNodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum)
}
else -> null
} ?: meshLog.raw_message
else -> meshLog.raw_message
}
return annotated ?: meshLog.raw_message
}
private fun annotatePacketLog(packet: MeshProtos.MeshPacket): String {
val builder = packet.toBuilder()
val hasDecoded = builder.hasDecoded()
val decoded = if (hasDecoded) builder.decoded else null
if (hasDecoded) builder.clearDecoded()
val baseText = builder.build().toString().trimEnd()
val result = if (hasDecoded && decoded != null) {
val decodedText = decoded.toString().trimEnd().prependIndent(" ")
"$baseText\ndecoded {\n$decodedText\n}"
} else {
baseText
}
return annotateRawMessage(result, packet.from, packet.to)
}
/**
@@ -274,6 +293,7 @@ class DebugViewModel @Inject constructor(
val messageType: String,
val formattedReceivedDate: String,
val logMessage: String,
val decodedPayload: String? = null,
)
companion object {
@@ -295,4 +315,54 @@ class DebugViewModel @Inject constructor(
}
fun setSelectedLogId(id: String?) { _selectedLogId.value = id }
/**
* Attempts to fully decode the payload of a MeshLog's MeshPacket using the appropriate protobuf definition,
* based on the portnum of the packet.
*
* For known portnums, the payload is parsed into its corresponding proto message and returned as a string.
* For text and alert messages, the payload is interpreted as UTF-8 text.
* For unknown portnums, the payload is shown as a hex string.
*
* @param log The MeshLog containing the packet and payload to decode.
* @return A human-readable string representation of the decoded payload, or an error message if decoding fails,
* or null if the log does not contain a decodable packet.
*/
private fun decodePayloadFromMeshLog(log: MeshLog): String? {
var result: String? = null
val packet = log.meshPacket
if (packet == null || !packet.hasDecoded()) {
result = null
} else {
val portnum = packet.decoded.portnumValue
val payload = packet.decoded.payload.toByteArray()
result = try {
when (portnum) {
PortNum.TEXT_MESSAGE_APP_VALUE,
PortNum.ALERT_APP_VALUE ->
payload.toString(Charsets.UTF_8)
PortNum.POSITION_APP_VALUE ->
MeshProtos.Position.parseFrom(payload).toString()
PortNum.WAYPOINT_APP_VALUE ->
MeshProtos.Waypoint.parseFrom(payload).toString()
PortNum.NODEINFO_APP_VALUE ->
MeshProtos.User.parseFrom(payload).toString()
PortNum.TELEMETRY_APP_VALUE ->
TelemetryProtos.Telemetry.parseFrom(payload).toString()
PortNum.ROUTING_APP_VALUE ->
MeshProtos.Routing.parseFrom(payload).toString()
PortNum.ADMIN_APP_VALUE ->
AdminProtos.AdminMessage.parseFrom(payload).toString()
PortNum.PAXCOUNTER_APP_VALUE ->
PaxcountProtos.Paxcount.parseFrom(payload).toString()
PortNum.STORE_FORWARD_APP_VALUE ->
StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString()
else -> payload.joinToString(" ") { "%02x".format(it) }
}
} catch (e: InvalidProtocolBufferException) {
"Failed to decode payload: ${e.message}"
}
}
return result
}
}

View File

@@ -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<MeshLog> = 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<Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
@@ -264,7 +267,7 @@ class MetricsViewModel @Inject constructor(
val timeFrame: StateFlow<TimeFrame> = _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.")
}
}

View File

@@ -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),
}

View File

@@ -237,6 +237,14 @@ internal fun DebugItem(
color = colorScheme.onSurface
)
)
// Show decoded payload if available
if (!log.decodedPayload.isNullOrBlank()) {
DecodedPayloadBlock(
decodedPayload = log.decodedPayload,
isSelected = isSelected,
colorScheme = colorScheme
)
}
}
}
}
@@ -780,3 +788,48 @@ private suspend fun exportAllLogs(context: Context, logs: List<UiMeshLog>) = wit
warn("Error:IOException: " + e.toString())
}
}
@Composable
private fun DecodedPayloadBlock(
decodedPayload: String,
isSelected: Boolean,
colorScheme: ColorScheme
) {
Text(
text = stringResource(id = R.string.debug_decoded_payload),
style = TextStyle(
fontSize = if (isSelected) 10.sp else 8.sp,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
Text(
text = "{",
style = TextStyle(
fontSize = if (isSelected) 10.sp else 8.sp,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
modifier = Modifier.padding(start = 8.dp, bottom = 2.dp)
)
Text(
text = decodedPayload,
softWrap = true,
style = TextStyle(
fontSize = if (isSelected) 10.sp else 8.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
),
modifier = Modifier.padding(start = 16.dp, bottom = 0.dp)
)
Text(
text = "}",
style = TextStyle(
fontSize = if (isSelected) 10.sp else 8.sp,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Pair<Int, Int>>,
bleSeries: List<Pair<Int, Int>>,
wifiSeries: List<Pair<Int, Int>>,
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<Pair<Int, Int>>, 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<Byte>()
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()
)
}
}
}

View File

@@ -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()

View File

@@ -164,6 +164,7 @@
<string name="text_messages">Text messages</string>
<string name="channel_invalid">This Channel URL is invalid and can not be used</string>
<string name="debug_panel">Debug Panel</string>
<string name="debug_decoded_payload">Decoded Payload:</string>
<string name="debug_logs_export">Export Logs</string>
<string name="debug_last_messages">500 last messages</string>
<string name="debug_filters">Filters</string>
@@ -788,5 +789,9 @@
<string name="clear_selection">Clear selection</string>
<string name="message_input_label">Message</string>
<string name="type_a_message">Type a message</string>
<string name="pax_metrics_log">PAX Metrics Log</string>
<string name="pax">PAX</string>
<string name="no_pax_metrics_logs">No PAX metrics logs available.</string>
<string name="wifi_devices">WiFi Devices</string>
<string name="ble_devices">BLE Devices</string>
</resources>