feat(auto): add local stats to Status tab and unit tests

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/2a077fa1-6261-4c35-a233-157ca1aa01b6

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-26 04:53:59 +00:00
committed by GitHub
parent afd56c874e
commit 483ab96c9b
5 changed files with 292 additions and 10 deletions

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 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 org.meshtastic.feature.auto
/**
* Snapshot of local device statistics displayed in the Status tab.
*
* Mirrors the key metrics shown by [org.meshtastic.feature.widget.LocalStatsWidget]:
* battery, channel/air utilization, node counts, uptime, and traffic counters.
*/
internal data class CarLocalStats(
val batteryLevel: Int = 0,
val hasBattery: Boolean = false,
val channelUtilization: Float = 0f,
val airUtilization: Float = 0f,
val totalNodes: Int = 0,
val onlineNodes: Int = 0,
val uptimeSeconds: Int = 0,
val numPacketsTx: Int = 0,
val numPacketsRx: Int = 0,
val numRxDupe: Int = 0,
)

View File

@@ -20,7 +20,9 @@ import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
@@ -213,4 +215,40 @@ internal object CarScreenDataBuilder {
contact.lastMessageTime != null -> formatShortDate(contact.lastMessageTime)
else -> ""
}
/**
* Builds a [CarLocalStats] snapshot from the device's [Node], [LocalStats], and node DB.
*
* Falls back to [Node.deviceMetrics] when [LocalStats] hasn't been populated yet — the
* same strategy used by [org.meshtastic.feature.widget.LocalStatsWidgetStateProvider].
*/
@Suppress("MagicNumber")
fun buildLocalStats(
ourNode: Node?,
stats: LocalStats,
allNodes: Collection<Node>,
): CarLocalStats {
val metrics = ourNode?.deviceMetrics
val batteryLevel = metrics?.battery_level ?: 0
val hasStats = stats.uptime_seconds != 0
val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
val uptimeSecs = if (hasStats) stats.uptime_seconds else metrics?.uptime_seconds ?: 0
val totalNodes = allNodes.size
val onlineNodes = allNodes.count { it.lastHeard > onlineTimeThreshold() }
return CarLocalStats(
batteryLevel = batteryLevel,
hasBattery = metrics?.battery_level != null,
channelUtilization = channelUtil,
airUtilization = airUtilTx,
totalNodes = totalNodes,
onlineNodes = onlineNodes,
uptimeSeconds = uptimeSecs,
numPacketsTx = stats.num_packets_tx,
numPacketsRx = stats.num_packets_rx,
numRxDupe = stats.num_rx_dupe,
)
}
}

View File

@@ -48,6 +48,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -57,7 +58,9 @@ import org.meshtastic.core.repository.ServiceRepository
* Root screen displayed in Android Auto.
*
* Renders a three-tab UI:
* - **Status** — Connection state and device name.
* - **Status** — Connection state, device name, and local device stats (battery,
* channel/air utilization, online nodes, uptime, traffic) — the same key metrics
* surfaced by the home-screen Local Stats widget.
* - **Favorites** — All nodes the user has starred, with online/hop status shown as a subtitle.
* - **Messages** — All conversations: active channels displayed first as permanent placeholders
* (always visible even when empty, sorted by channel index), followed by DM conversations
@@ -102,6 +105,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
private var activeTabId = TAB_STATUS
private var connectionState: ConnectionState = ConnectionState.Disconnected
/**
* Local device statistics for the Status tab — battery, utilization, nodes, uptime.
* Mirrors the key metrics shown by the home-screen Local Stats widget.
*/
private var localStats: CarLocalStats = CarLocalStats()
/**
* Favorite nodes sorted alphabetically by long name. Updated reactively from
* [NodeRepository.nodeDBbyNum] whenever the user stars or un-stars a node.
@@ -173,7 +182,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
.map { db -> CarScreenDataBuilder.sortFavorites(db.values) }
.distinctUntilChanged()
// All three data sources feed a single combined collector so that each batch of
val localStatsFlow = combine(
nodeRepository.ourNodeInfo,
nodeRepository.localStats,
nodeRepository.nodeDBbyNum,
) { ourNode, stats, nodeDb ->
CarScreenDataBuilder.buildLocalStats(ourNode, stats, nodeDb.values)
}.distinctUntilChanged()
// All data sources feed a single combined collector so that each batch of
// repository changes produces exactly one invalidate() call, avoiding unnecessary
// template rebuilds and staying well within the host's update-rate budget.
scope.launch {
@@ -181,11 +198,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
serviceRepository.connectionState,
favoritesFlow,
contactsFlow,
) { connState, favs, ctcts -> Triple(connState, favs, ctcts) }
.collect { (connState, favs, ctcts) ->
connectionState = connState
favorites = favs
contacts = ctcts
localStatsFlow,
) { connState, favs, ctcts, stats ->
CombinedState(connState, favs, ctcts, stats)
}
.collect { state ->
connectionState = state.connectionState
favorites = state.favorites
contacts = state.contacts
localStats = state.localStats
isLoading = false
invalidate()
}
@@ -267,11 +288,19 @@ class MeshtasticCarScreen(carContext: CarContext) :
// ---- Individual template builders ----
private fun buildStatusTemplate(): ListTemplate =
ListTemplate.Builder()
/**
* Builds the Status tab: connection state + local device stats mirroring the home-screen
* Local Stats widget (battery, channel/air utilization, node counts, uptime, traffic).
*/
private fun buildStatusTemplate(): ListTemplate {
val items = ItemList.Builder()
items.addItem(buildStatusRow())
buildLocalStatsRows().forEach { items.addItem(it) }
return ListTemplate.Builder()
.setTitle(carContext.getString(R.string.auto_tab_status))
.setSingleList(ItemList.Builder().addItem(buildStatusRow()).build())
.setSingleList(items.build())
.build()
}
/**
* Builds the Favorites tab: one row per starred node, mirroring the key status info shown
@@ -314,6 +343,11 @@ class MeshtasticCarScreen(carContext: CarContext) :
var remaining = listContentLimit
items.addItem(buildStatusRow())
remaining--
val statsRows = buildLocalStatsRows()
statsRows.take(remaining).forEach { row ->
items.addItem(row)
remaining--
}
// Give each section at most half the remaining space so neither dominates.
val halfRemaining = remaining / 2
favorites.take(halfRemaining).forEach { node ->
@@ -344,6 +378,70 @@ class MeshtasticCarScreen(carContext: CarContext) :
.build()
}
/**
* Builds rows for local device statistics — the same key metrics the home-screen widget
* surfaces: battery, channel/air utilization, online nodes, uptime, and packet traffic.
*
* Only shown when connected ([CarLocalStats.hasBattery] is a proxy for "device metrics
* received"). Returns an empty list when disconnected.
*/
@Suppress("MagicNumber")
private fun buildLocalStatsRows(): List<Row> {
val s = localStats
if (!s.hasBattery && s.totalNodes == 0) return emptyList()
val rows = mutableListOf<Row>()
if (s.hasBattery) {
val batteryValue = if (s.batteryLevel > 100) {
carContext.getString(R.string.auto_stats_powered)
} else {
"${s.batteryLevel}%"
}
rows += Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_battery, batteryValue))
.addText(
carContext.getString(
R.string.auto_stats_channel_util,
"%.1f%%".format(s.channelUtilization),
"%.1f%%".format(s.airUtilization),
),
)
.setBrowsable(false)
.build()
}
rows += Row.Builder()
.setTitle(carContext.getString(R.string.auto_stats_nodes, s.onlineNodes, s.totalNodes))
.addTextIfNotEmpty(
if (s.uptimeSeconds > 0) {
carContext.getString(
R.string.auto_stats_uptime,
formatUptime(s.uptimeSeconds),
)
} else {
""
},
)
.setBrowsable(false)
.build()
if (s.numPacketsTx > 0 || s.numPacketsRx > 0) {
rows += Row.Builder()
.setTitle(
carContext.getString(
R.string.auto_stats_traffic,
s.numPacketsTx,
s.numPacketsRx,
s.numRxDupe,
),
)
.setBrowsable(false)
.build()
}
return rows
}
/**
* Builds a single favorite-node row.
*
@@ -428,6 +526,13 @@ class MeshtasticCarScreen(carContext: CarContext) :
return ListTemplate.Builder().setTitle(title).setSingleList(listBuilder.build()).build()
}
private data class CombinedState(
val connectionState: ConnectionState,
val favorites: List<Node>,
val contacts: List<CarContact>,
val localStats: CarLocalStats,
)
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"

View File

@@ -48,6 +48,14 @@
<!-- %d is replaced with the channel index at runtime -->
<string name="auto_channel_number">Channel %d</string>
<!-- Local stats (Status tab) -->
<string name="auto_stats_battery">Battery: %1$s</string>
<string name="auto_stats_powered">Powered</string>
<string name="auto_stats_channel_util">Ch Util: %1$s · Air Util: %2$s</string>
<string name="auto_stats_nodes">Nodes: %1$d/%2$d online</string>
<string name="auto_stats_uptime">Uptime: %1$s</string>
<string name="auto_stats_traffic">TX: %1$d · RX: %2$d · Dupe: %3$d</string>
<!-- Generic fallback label when a node has no name -->
<string name="auto_unknown">Unknown</string>
</resources>

View File

@@ -27,6 +27,7 @@ import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.Node
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import kotlin.test.Test
@@ -492,6 +493,100 @@ class CarScreenDataBuilderTest {
CarScreenDataBuilder.contactSecondaryText(contact, formatShortDate = { "should not appear" }) shouldBe "3 unread"
}
// ---- buildLocalStats ----
@Test
fun `buildLocalStats - returns defaults when ourNode is null and stats are empty`() {
val result = CarScreenDataBuilder.buildLocalStats(null, LocalStats(), emptyList())
result.hasBattery shouldBe false
result.batteryLevel shouldBe 0
result.channelUtilization shouldBe 0f
result.airUtilization shouldBe 0f
result.totalNodes shouldBe 0
result.onlineNodes shouldBe 0
result.uptimeSeconds shouldBe 0
}
@Test
fun `buildLocalStats - reads battery from device metrics`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(battery_level = 85),
)
val result = CarScreenDataBuilder.buildLocalStats(node, LocalStats(), emptyList())
result.hasBattery shouldBe true
result.batteryLevel shouldBe 85
}
@Test
fun `buildLocalStats - prefers LocalStats utilization over device metrics`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(channel_utilization = 5f, air_util_tx = 1f),
)
val stats = LocalStats(
uptime_seconds = 100,
channel_utilization = 18.5f,
air_util_tx = 3.2f,
)
val result = CarScreenDataBuilder.buildLocalStats(node, stats, emptyList())
result.channelUtilization shouldBe 18.5f
result.airUtilization shouldBe 3.2f
result.uptimeSeconds shouldBe 100
}
@Test
fun `buildLocalStats - falls back to device metrics when LocalStats uptime is zero`() {
val node = Node(
num = 1,
user = User(long_name = "Me"),
deviceMetrics = DeviceMetrics(
channel_utilization = 5f,
air_util_tx = 1f,
uptime_seconds = 3600,
),
)
val result = CarScreenDataBuilder.buildLocalStats(node, LocalStats(), emptyList())
result.channelUtilization shouldBe 5f
result.airUtilization shouldBe 1f
result.uptimeSeconds shouldBe 3600
}
@Test
fun `buildLocalStats - counts total and online nodes`() {
val nowSecs = (System.currentTimeMillis() / 1000).toInt()
val nodes = listOf(
Node(num = 1, lastHeard = nowSecs),
Node(num = 2, lastHeard = nowSecs),
Node(num = 3, lastHeard = 0), // offline
)
val result = CarScreenDataBuilder.buildLocalStats(null, LocalStats(), nodes)
result.totalNodes shouldBe 3
result.onlineNodes shouldBe 2
}
@Test
fun `buildLocalStats - copies traffic counters from LocalStats`() {
val stats = LocalStats(
uptime_seconds = 1,
num_packets_tx = 145,
num_packets_rx = 892,
num_rx_dupe = 42,
)
val result = CarScreenDataBuilder.buildLocalStats(null, stats, emptyList())
result.numPacketsTx shouldBe 145
result.numPacketsRx shouldBe 892
result.numRxDupe shouldBe 42
}
// ---- helpers ----
/** Returns a node guaranteed to be online (lastHeard == now). */