diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt
new file mode 100644
index 000000000..182f74589
--- /dev/null
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarLocalStats.kt
@@ -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 .
+ */
+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,
+)
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
index 49e701ff0..d54afb977 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilder.kt
@@ -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,
+ ): 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,
+ )
+ }
}
diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
index f8e80c667..0d10e27b3 100644
--- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
+++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt
@@ -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 {
+ val s = localStats
+ if (!s.hasBattery && s.totalNodes == 0) return emptyList()
+ val rows = mutableListOf()
+
+ 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,
+ val contacts: List,
+ val localStats: CarLocalStats,
+ )
+
companion object {
private const val TAB_STATUS = "status"
private const val TAB_FAVORITES = "favorites"
diff --git a/feature/auto/src/main/res/values/strings.xml b/feature/auto/src/main/res/values/strings.xml
index 7077a4f18..222a5a152 100644
--- a/feature/auto/src/main/res/values/strings.xml
+++ b/feature/auto/src/main/res/values/strings.xml
@@ -48,6 +48,14 @@
Channel %d
+
+ Battery: %1$s
+ Powered
+ Ch Util: %1$s · Air Util: %2$s
+ Nodes: %1$d/%2$d online
+ Uptime: %1$s
+ TX: %1$d · RX: %2$d · Dupe: %3$d
+
Unknown
diff --git a/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt b/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
index 2d753de65..7ef327295 100644
--- a/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
+++ b/feature/auto/src/test/kotlin/org/meshtastic/feature/auto/CarScreenDataBuilderTest.kt
@@ -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). */