mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
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:
committed by
GitHub
parent
afd56c874e
commit
483ab96c9b
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user