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). */