From c690ddc7eab2ac99993cb9ba01000a03c65decdc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:09:21 -0600 Subject: [PATCH] feat: Accurately display outgoing diagnostic packets (#4569) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/service/MeshMessageProcessor.kt | 2 +- .../geeksville/mesh/service/PacketHandler.kt | 2 +- .../mesh/service/MeshMessageProcessorTest.kt | 31 ++ .../mesh/service/PacketHandlerTest.kt | 15 + .../core/data/repository/MeshLogRepository.kt | 136 ++++--- .../core/data/repository/NodeRepository.kt | 35 +- .../data/repository/MeshLogRepositoryTest.kt | 112 ++++- .../data/repository/NodeRepositoryTest.kt | 139 +++++++ .../core/database/dao/MeshLogDao.kt | 4 +- .../core/database/entity/MeshLog.kt | 23 ++ .../meshtastic/core/database/model/Node.kt | 20 + .../core/database/model/NodeTest.kt | 49 +++ .../meshtastic/core/model/util/Extensions.kt | 12 + .../core/model/util/ExtensionsTest.kt | 100 +++++ .../feature/map/node/NodeMapViewModel.kt | 28 +- .../feature/node/component/PositionSection.kt | 53 ++- .../node/detail/NodeDetailViewModel.kt | 384 ++++++++---------- .../feature/node/metrics/MetricsViewModel.kt | 158 ++++--- 18 files changed, 922 insertions(+), 381 deletions(-) create mode 100644 core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt create mode 100644 core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt create mode 100644 core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt index e0c578580..2633e74c4 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt @@ -189,7 +189,7 @@ constructor( message_type = "Packet", received_date = nowMillis, raw_message = packet.toString(), - fromNum = packet.from, + fromNum = if (packet.from == myNodeNum) MeshLog.NODE_NUM_LOCAL else packet.from, portNum = decoded.portnum.value, fromRadio = FromRadio(packet = packet), ) diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index ee886972c..4104e5a8c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -94,7 +94,7 @@ constructor( message_type = "Packet", received_date = nowMillis, raw_message = packet.toString(), - fromNum = packet.from ?: 0, + fromNum = MeshLog.NODE_NUM_LOCAL, // Outgoing packets are always from the local node portNum = packet.decoded?.portnum?.value ?: 0, fromRadio = FromRadio(packet = packet), ) diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt index 347180b51..9b3aa4cfc 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt @@ -16,6 +16,7 @@ */ package com.geeksville.mesh.service +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -26,6 +27,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository +import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.service.ServiceRepository import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket @@ -88,4 +90,33 @@ class MeshMessageProcessorTest { verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) } } + + @Test + fun `packets from local node are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) { + val myNodeNum = 1234 + val packet = MeshPacket(from = myNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) + + isNodeDbReady.value = true + testScheduler.runCurrent() + + processor.handleReceivedMeshPacket(packet, myNodeNum) + testScheduler.runCurrent() // wait for log insert job + + coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) } + } + + @Test + fun `packets from remote nodes are logged with their node number`() = runTest(testDispatcher) { + val myNodeNum = 1234 + val remoteNodeNum = 5678 + val packet = MeshPacket(from = remoteNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) + + isNodeDbReady.value = true + testScheduler.runCurrent() + + processor.handleReceivedMeshPacket(packet, myNodeNum) + testScheduler.runCurrent() + + coVerify { meshLogRepository.insert(match { log -> log.fromNum == remoteNodeNum }) } + } } diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt index b27e77113..0ad9629f2 100644 --- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt @@ -17,6 +17,7 @@ package com.geeksville.mesh.service import com.geeksville.mesh.repository.radio.RadioInterfaceService +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -28,8 +29,11 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.PacketRepository +import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.service.ConnectionState +import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.ToRadio @@ -97,4 +101,15 @@ class PacketHandlerTest { handler.handleQueueStatus(status) testScheduler.runCurrent() } + + @Test + fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) { + val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) + val toRadio = ToRadio(packet = packet) + + handler.sendToRadio(toRadio) + testScheduler.runCurrent() + + coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) } + } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt index 72ac9d135..6a9a1dbc6 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt @@ -19,14 +19,16 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext +import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.model.util.nowMillis import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.proto.MeshPacket @@ -34,33 +36,83 @@ import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry import javax.inject.Inject +import javax.inject.Singleton +/** + * Repository for managing and retrieving logs from the local database. + * + * This repository provides methods for inserting, deleting, and querying logs, including specialized methods for + * telemetry and traceroute data. + */ @Suppress("TooManyFunctions") +@Singleton class MeshLogRepository @Inject constructor( private val dbManager: DatabaseManager, private val dispatchers: CoroutineDispatchers, private val meshLogPrefs: MeshLogPrefs, + private val nodeInfoReadDataSource: NodeInfoReadDataSource, ) { - fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = - dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItems) }.flowOn(dispatchers.io).conflate() - fun getAllLogsUnbounded(): Flow> = dbManager.currentDb - .flatMapLatest { it.meshLogDao().getAllLogs(Int.MAX_VALUE) } - .flowOn(dispatchers.io) - .conflate() - - fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = dbManager.currentDb - .flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItems) } + /** Retrieves all [MeshLog]s in the database, up to [maxItem]. */ + fun getAllLogs(maxItem: Int = MAX_MESH_PACKETS): Flow> = + dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItem) }.flowOn(dispatchers.io) + + /** Retrieves all [MeshLog]s in the database in the order they were received. */ + fun getAllLogsInReceiveOrder(maxItem: Int = MAX_MESH_PACKETS): Flow> = + dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }.flowOn(dispatchers.io) + + /** Retrieves all [MeshLog]s in the database without any limit. */ + fun getAllLogsUnbounded(): Flow> = getAllLogs(Int.MAX_VALUE) + + /** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */ + fun getLogsFrom(nodeNum: Int, portNum: Int): Flow> = dbManager.currentDb + .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS) } + .distinctUntilChanged() .flowOn(dispatchers.io) + + /** Retrieves all [MeshLog]s containing [MeshPacket]s for a specific [nodeNum]. */ + fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = -1): Flow> = + getLogsFrom(nodeNum, portNum).map { list -> list.mapNotNull { it.fromRadio.packet } }.flowOn(dispatchers.io) + + /** Retrieves telemetry history for a specific node, automatically handling local node redirection. */ + fun getTelemetryFrom(nodeNum: Int): Flow> = effectiveLogId(nodeNum) + .flatMapLatest { logId -> + dbManager.currentDb + .flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) } + .distinctUntilChanged() + .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } + } + .flowOn(dispatchers.io) + + /** + * Retrieves all outgoing request logs for a specific [targetNodeNum] and [portNum]. + * + * A request log is defined as an outgoing packet (`fromNum = 0`) where `want_response` is true. + */ + fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow> = dbManager.currentDb + .flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, MAX_MESH_PACKETS) } + .map { list -> + list.filter { log -> + val packet = log.fromRadio.packet ?: return@filter false + log.fromNum == MeshLog.NODE_NUM_LOCAL && + packet.to == targetNodeNum && + packet.decoded?.want_response == true + } + } + .distinctUntilChanged() .conflate() + @Suppress("CyclomaticComplexMethod") private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { - val payload = log.fromRadio.packet?.decoded?.payload ?: return@runCatching null - val telemetry = Telemetry.ADAPTER.decode(payload) + val decoded = log.fromRadio.packet?.decoded ?: return@runCatching null + // Requests for telemetry (want_response = true) should not be logged as data points. + if (decoded.want_response == true) return@runCatching null + + val telemetry = Telemetry.ADAPTER.decode(decoded.payload) telemetry.copy( - time = (log.received_date / MILLIS_TO_SECONDS).toInt(), + time = (log.received_date / MILLIS_PER_SEC).toInt(), environment_metrics = telemetry.environment_metrics?.let { metrics -> metrics.copy( @@ -81,63 +133,47 @@ constructor( } .getOrNull() - fun getTelemetryFrom(nodeNum: Int): Flow> = dbManager.currentDb - .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) } + /** Returns a flow that maps a [nodeNum] to [MeshLog.NODE_NUM_LOCAL] if it is the locally connected node. */ + private fun effectiveLogId(nodeNum: Int): Flow = nodeInfoReadDataSource + .myNodeInfoFlow() + .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } .distinctUntilChanged() - .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } - .flowOn(dispatchers.io) - fun getLogsFrom( - nodeNum: Int, - portNum: Int = PortNum.UNKNOWN_APP.value, - maxItem: Int = MAX_MESH_PACKETS, - ): Flow> = dbManager.currentDb - .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, maxItem) } - .distinctUntilChanged() - .flowOn(dispatchers.io) - - /* - * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. - * If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'. - */ - fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = PortNum.UNKNOWN_APP.value): Flow> = - getLogsFrom(nodeNum, portNum) - .mapLatest { list -> list.mapNotNull { it.fromRadio.packet } } - .flowOn(dispatchers.io) - - fun getMyNodeInfo(): Flow = getLogsFrom(0, 0) + /** Returns the cached [MyNodeInfo] from the system logs. */ + fun getMyNodeInfo(): Flow = dbManager.currentDb + .flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, MAX_MESH_PACKETS) } .mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo } .flowOn(dispatchers.io) + /** Persists a new log entry to the database if logging is enabled in preferences. */ suspend fun insert(log: MeshLog) = withContext(dispatchers.io) { if (!meshLogPrefs.loggingEnabled) return@withContext dbManager.currentDb.value.meshLogDao().insert(log) } + /** Clears all logs from the database. */ suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() } + /** Deletes a specific log entry by its [uuid]. */ suspend fun deleteLog(uuid: String) = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLog(uuid) } - suspend fun deleteLogs(nodeNum: Int, portNum: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLogs(nodeNum, portNum) } + /** Deletes all logs associated with a specific [nodeNum] and [portNum]. */ + suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) { + val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum + val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum + dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum) + } + /** Prunes the log database based on the configured [retentionDays]. */ @Suppress("MagicNumber") suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) { - if (retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) return@withContext - - val cutoffTimestamp = - if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) { - nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - } else { - nowMillis - (retentionDays * TimeConstants.ONE_DAY.inWholeMilliseconds) - } - dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTimestamp) + val cutoffTime = nowMillis - (retentionDays.toLong() * 24 * 60 * 60 * 1000) + dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTime) } companion object { - private const val MAX_ITEMS = 500 - private const val MAX_MESH_PACKETS = 10000 - private const val MILLIS_TO_SECONDS = 1000 + private const val MAX_MESH_PACKETS = 5000 + private const val MILLIS_PER_SEC = 1000L } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt index fbf55e758..6073f6807 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -35,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource +import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity @@ -49,6 +51,7 @@ import org.meshtastic.proto.User import javax.inject.Inject import javax.inject.Singleton +/** Repository for managing node-related data, including hardware info, node database, and identity. */ @Singleton @Suppress("TooManyFunctions") class NodeRepository @@ -59,24 +62,26 @@ constructor( private val nodeInfoWriteDataSource: NodeInfoWriteDataSource, private val dispatchers: CoroutineDispatchers, ) { - // hardware info about our local device (can be null) + /** Hardware info about our local device (can be null if not connected). */ val myNodeInfo: StateFlow = nodeInfoReadDataSource .myNodeInfoFlow() .flowOn(dispatchers.io) .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) - // our node info private val _ourNodeInfo = MutableStateFlow(null) + + /** Information about the locally connected node, as seen from the mesh. */ val ourNodeInfo: StateFlow get() = _ourNodeInfo - // The unique userId of our node private val _myId = MutableStateFlow(null) + + /** The unique userId (hex string) of our local node. */ val myId: StateFlow get() = _myId - // A map from nodeNum to Node + /** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */ val nodeDBbyNum: StateFlow> = nodeInfoReadDataSource .nodeDBbyNumFlow() @@ -102,14 +107,25 @@ constructor( .launchIn(processLifecycle.coroutineScope) } + /** + * Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally + * connected node. + */ + fun effectiveLogNodeId(nodeNum: Int): Flow = myNodeInfo + .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } + .distinctUntilChanged() + fun getNodeDBbyNum() = nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } } + /** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */ fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) + /** Returns the [User] info for a given [nodeNum]. */ fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) + /** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */ fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user ?: User( id = userId, @@ -128,6 +144,7 @@ constructor( hw_model = HardwareModel.UNSET, ) + /** Returns a flow of nodes filtered and sorted according to the parameters. */ fun getNodes( sort: NodeSortOption = NodeSortOption.LAST_HEARD, filter: String = "", @@ -146,21 +163,27 @@ constructor( .flowOn(dispatchers.io) .conflate() + /** Upserts a [NodeEntity] to the database. */ suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node) } + /** Installs initial configuration data (local info and remote nodes) into the database. */ suspend fun installConfig(mi: MyNodeEntity, nodes: List) = withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi, nodes) } + /** Deletes all nodes from the database, optionally preserving favorites. */ suspend fun clearNodeDB(preserveFavorites: Boolean = false) = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) } + /** Clears the local node's connection info. */ suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() } + /** Deletes a node and its metadata by [num]. */ suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { nodeInfoWriteDataSource.deleteNode(num) nodeInfoWriteDataSource.deleteMetadata(num) } + /** Deletes multiple nodes and their metadata. */ suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { nodeInfoWriteDataSource.deleteNodes(nodeNums) nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) } @@ -172,9 +195,11 @@ constructor( suspend fun getUnknownNodes(): List = withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes() } + /** Persists hardware metadata for a node. */ suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(metadata) } + /** Flow emitting the count of nodes currently considered "online". */ val onlineNodeCount: Flow = nodeInfoReadDataSource .nodeDBbyNumFlow() @@ -182,6 +207,7 @@ constructor( .flowOn(dispatchers.io) .conflate() + /** Flow emitting the total number of nodes in the database. */ val totalNodeCount: Flow = nodeInfoReadDataSource .nodeDBbyNumFlow() @@ -189,6 +215,7 @@ constructor( .flowOn(dispatchers.io) .conflate() + /** Updates the personal notes field for a node. */ suspend fun setNodeNotes(num: Int, notes: String) = withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } } diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt index a5ac04dab..2e19ac12f 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt @@ -16,19 +16,24 @@ */ package org.meshtastic.core.data.repository +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test +import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.util.nowMillis import org.meshtastic.core.prefs.meshlog.MeshLogPrefs @@ -46,14 +51,16 @@ class MeshLogRepositoryTest { private val appDatabase: MeshtasticDatabase = mockk() private val meshLogDao: MeshLogDao = mockk() private val meshLogPrefs: MeshLogPrefs = mockk() + private val nodeInfoReadDataSource: NodeInfoReadDataSource = mockk() private val testDispatcher = UnconfinedTestDispatcher() private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) - private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs) + private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource) init { every { dbManager.currentDb } returns MutableStateFlow(appDatabase) every { appDatabase.meshLogDao() } returns meshLogDao + every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null) } @Test @@ -110,4 +117,107 @@ class MeshLogRepositoryTest { // Should be NaN as per repository logic for missing fields assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f) } + + @Test + fun `getRequestLogs filters correctly`() = runTest(testDispatcher) { + val targetNode = 123 + val otherNode = 456 + val port = PortNum.TRACEROUTE_APP + + val logs = + listOf( + // Valid request + MeshLog( + uuid = "1", + message_type = "Packet", + received_date = nowMillis, + raw_message = "", + fromNum = 0, + portNum = port.value, + fromRadio = + FromRadio( + packet = + MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)), + ), + ), + // Wrong target + MeshLog( + uuid = "2", + message_type = "Packet", + received_date = nowMillis, + raw_message = "", + fromNum = 0, + portNum = port.value, + fromRadio = + FromRadio( + packet = + MeshPacket(to = otherNode, decoded = Data(portnum = port, want_response = true)), + ), + ), + // Not a request (want_response = false) + MeshLog( + uuid = "3", + message_type = "Packet", + received_date = nowMillis, + raw_message = "", + fromNum = 0, + portNum = port.value, + fromRadio = + FromRadio( + packet = + MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = false)), + ), + ), + // Wrong fromNum + MeshLog( + uuid = "4", + message_type = "Packet", + received_date = nowMillis, + raw_message = "", + fromNum = 789, + portNum = port.value, + fromRadio = + FromRadio( + packet = + MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)), + ), + ), + ) + + every { meshLogDao.getLogsFrom(0, port.value, any()) } returns MutableStateFlow(logs) + + val result = repository.getRequestLogs(targetNode, port).first() + + assertEquals(1, result.size) + assertEquals("1", result[0].uuid) + } + + @Test + fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) { + val localNodeNum = 999 + val port = 100 + val myNodeEntity = mockk() + every { myNodeEntity.myNodeNum } returns localNodeNum + every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity) + coEvery { meshLogDao.deleteLogs(any(), any()) } returns Unit + + repository.deleteLogs(localNodeNum, port) + + coVerify { meshLogDao.deleteLogs(MeshLog.NODE_NUM_LOCAL, port) } + } + + @Test + fun `deleteLogs preserves remote node numbers`() = runTest(testDispatcher) { + val localNodeNum = 999 + val remoteNodeNum = 888 + val port = 100 + val myNodeEntity = mockk() + every { myNodeEntity.myNodeNum } returns localNodeNum + every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity) + coEvery { meshLogDao.deleteLogs(any(), any()) } returns Unit + + repository.deleteLogs(remoteNodeNum, port) + + coVerify { meshLogDao.deleteLogs(remoteNodeNum, port) } + } } diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt new file mode 100644 index 000000000..4a25e50d5 --- /dev/null +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt @@ -0,0 +1,139 @@ +/* + * 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.core.data.repository + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.coroutineScope +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.data.datasource.NodeInfoReadDataSource +import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource +import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.di.CoroutineDispatchers + +@OptIn(ExperimentalCoroutinesApi::class) +class NodeRepositoryTest { + + private val readDataSource: NodeInfoReadDataSource = mockk(relaxed = true) + private val writeDataSource: NodeInfoWriteDataSource = mockk(relaxed = true) + private val lifecycle: Lifecycle = mockk(relaxed = true) + private val lifecycleScope: LifecycleCoroutineScope = mockk() + + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) + + private val myNodeInfoFlow = MutableStateFlow(null) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkStatic("androidx.lifecycle.LifecycleKt") + every { lifecycleScope.coroutineContext } returns testDispatcher + Job() + every { lifecycle.coroutineScope } returns lifecycleScope + every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow + every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow(emptyMap()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity( + myNodeNum = nodeNum, + model = "model", + firmwareVersion = "1.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + ) + + @Test + fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) { + val myNodeNum = 12345 + myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) + + val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers) + testScheduler.runCurrent() + + val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first() + + assertEquals(MeshLog.NODE_NUM_LOCAL, result) + } + + @Test + fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) { + val myNodeNum = 12345 + val remoteNodeNum = 67890 + myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) + + val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers) + testScheduler.runCurrent() + + val result = repository.effectiveLogNodeId(remoteNodeNum).first() + + assertEquals(remoteNodeNum, result) + } + + @Test + fun `effectiveLogNodeId updates when local node number changes`() = runTest(testDispatcher) { + val firstNodeNum = 111 + val secondNodeNum = 222 + val targetNodeNum = 111 + + myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum) + val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers) + testScheduler.runCurrent() + + // Initially should be mapped to LOCAL because it matches + assertEquals( + MeshLog.NODE_NUM_LOCAL, + repository.effectiveLogNodeId(targetNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first(), + ) + + // Change local node num + myNodeInfoFlow.value = createMyNodeEntity(secondNodeNum) + testScheduler.runCurrent() + + // Now it shouldn't match, so should return the original num + assertEquals( + targetNodeNum, + repository.effectiveLogNodeId(targetNodeNum).filter { it == targetNodeNum }.first(), + ) + } +} diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 8241714a0..669f86aee 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -34,12 +34,12 @@ interface MeshLogDao { /** * Retrieves [MeshLog]s matching 'from_num' (nodeNum) and 'port_num' (PortNum). * - * @param portNum If 0, returns all MeshPackets. Otherwise, filters by 'port_num'. + * @param portNum If -1, returns all logs regardless of port. If 0, returns logs with port 0. */ @Query( """ SELECT * FROM log - WHERE from_num = :fromNum AND (:portNum = 0 AND port_num != 0 OR port_num = :portNum) + WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) ORDER BY received_date DESC LIMIT 0,:maxItem """, ) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt index 52c2943cf..7146d840b 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/MeshLog.kt @@ -28,6 +28,19 @@ import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.Position +/** + * Represents a log entry in the database. + * + * Logs are used for auditing radio traffic, telemetry history, and debugging. + * + * @property uuid Unique identifier for this log entry. + * @property message_type The type of message (e.g., "Packet", "Telemetry", "LogRecord"). + * @property received_date Timestamp when the log was recorded. + * @property raw_message A string representation of the raw data. + * @property fromNum The node number that sent the packet. + * @property portNum The application port number associated with the data. + * @property fromRadio The decoded [FromRadio] protobuf object. + */ @Suppress("EmptyCatchBlock", "SwallowedException", "ConstructorParameterNaming") @Entity(tableName = "log", indices = [Index(value = ["from_num"]), Index(value = ["port_num"])]) data class MeshLog( @@ -59,4 +72,14 @@ data class MeshLog( null } } ?: nodeInfo?.position + + companion object { + /** + * The node number used to represent the local node in the logs. + * + * Using 0 instead of the actual node number ensures log continuity even if the radio hardware or local ID + * changes. + */ + const val NODE_NUM_LOCAL = 0 + } } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt index 1207ead19..8b3df569a 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt @@ -20,6 +20,7 @@ import android.graphics.Color import okio.ByteString import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.GPSFormat import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.latLongToMeter @@ -36,6 +37,11 @@ import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +/** + * Domain model representing a node in the mesh network. + * + * This class aggregates user information, position data, and hardware metrics. + */ @Suppress("MagicNumber") data class Node( val num: Int, @@ -205,6 +211,20 @@ data class Node( nodeStatus = nodeStatus, lastTransport = lastTransport, ) + + companion object { + private const val DEFAULT_ID_SUFFIX_LENGTH = 4 + + /** Creates a fallback [Node] when the node is not found in the database. */ + fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node { + val userId = DataPacket.nodeNumToDefaultId(nodeNum) + val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH) + val longName = "$fallbackNamePrefix $safeUserId" + val defaultUser = + User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET) + return Node(num = nodeNum, user = defaultUser) + } + } } fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in diff --git a/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt b/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt new file mode 100644 index 000000000..5a4db388e --- /dev/null +++ b/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025-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.core.database.model + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.meshtastic.proto.HardwareModel + +class NodeTest { + + @Test + fun `createFallback produces expected node data`() { + val nodeNum = 0x12345678 + val prefix = "Node" + val node = Node.createFallback(nodeNum, prefix) + + assertEquals(nodeNum, node.num) + assertEquals("!12345678", node.user.id) + assertEquals("Node 5678", node.user.long_name) + assertEquals("5678", node.user.short_name) + assertEquals(HardwareModel.UNSET, node.user.hw_model) + } + + @Test + fun `createFallback pads short IDs with zeros`() { + val nodeNum = 0x1 + val prefix = "Node" + val node = Node.createFallback(nodeNum, prefix) + + assertEquals(nodeNum, node.num) + assertEquals("!00000001", node.user.id) + assertEquals("Node 0001", node.user.long_name) + assertEquals("0001", node.user.short_name) + } +} diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt index 400438248..ef7b7ee8d 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -14,11 +14,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("TooManyFunctions") + package org.meshtastic.core.model.util import org.meshtastic.core.model.BuildConfig import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Telemetry /** * When printing strings to logs sometimes we want to print useful debugging information about users or positions. But @@ -74,3 +77,12 @@ fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMe transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT1 || transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT2 || transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT3 + +/** Returns true if this packet is a direct LoRa signal (not MQTT, and hop count matches). */ +fun MeshPacket.isDirectSignal(): Boolean = rx_time > 0 && hop_start == hop_limit && via_mqtt != true && isLora() + +/** Returns true if this telemetry packet contains valid, plot-able environment metrics. */ +fun Telemetry.hasValidEnvironmentMetrics(): Boolean { + val metrics = this.environment_metrics ?: return false + return metrics.relative_humidity != null && metrics.temperature != null && !metrics.temperature!!.isNaN() +} diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt new file mode 100644 index 000000000..ae4690a52 --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025-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.core.model.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Telemetry + +class ExtensionsTest { + + @Test + fun `isDirectSignal returns true for valid LoRa non-MQTT packets with matching hops`() { + val packet = + MeshPacket( + rx_time = 123456, + hop_start = 3, + hop_limit = 3, + via_mqtt = false, + transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA, + ) + assertTrue(packet.isDirectSignal()) + } + + @Test + fun `isDirectSignal returns false if via MQTT`() { + val packet = + MeshPacket( + rx_time = 123456, + hop_start = 3, + hop_limit = 3, + via_mqtt = true, + transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA, + ) + assertFalse(packet.isDirectSignal()) + } + + @Test + fun `isDirectSignal returns false if hops do not match`() { + val packet = + MeshPacket( + rx_time = 123456, + hop_start = 3, + hop_limit = 2, + via_mqtt = false, + transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA, + ) + assertFalse(packet.isDirectSignal()) + } + + @Test + fun `isDirectSignal returns false if rx_time is zero`() { + val packet = + MeshPacket( + rx_time = 0, + hop_start = 3, + hop_limit = 3, + via_mqtt = false, + transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA, + ) + assertFalse(packet.isDirectSignal()) + } + + @Test + fun `hasValidEnvironmentMetrics returns true when temperature and humidity are present and valid`() { + val telemetry = + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = 50.0f)) + assertTrue(telemetry.hasValidEnvironmentMetrics()) + } + + @Test + fun `hasValidEnvironmentMetrics returns false if temperature is NaN`() { + val telemetry = + Telemetry(environment_metrics = EnvironmentMetrics(temperature = Float.NaN, relative_humidity = 50.0f)) + assertFalse(telemetry.hasValidEnvironmentMetrics()) + } + + @Test + fun `hasValidEnvironmentMetrics returns false if humidity is missing`() { + val telemetry = + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = null)) + assertFalse(telemetry.hasValidEnvironmentMetrics()) + } +} diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index 582cd2ce2..0fb5f6e18 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt @@ -23,12 +23,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.toList import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.prefs.map.MapPrefs import org.meshtastic.core.ui.util.toPosition @@ -58,17 +60,23 @@ constructor( val applicationId = buildConfigProvider.applicationId + private val ourNodeNumFlow = nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() }.distinctUntilChanged() + val positionLogs: StateFlow> = - meshLogRepository - .getMeshPacketsFrom(destNum!!, PortNum.POSITION_APP.value) - .map { packets -> - packets - .mapNotNull { it.toPosition() } - .asFlow() - .distinctUntilChanged { old, new -> - old.time == new.time || (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) - } - .toList() + ourNodeNumFlow + .map { if (destNum == it) MeshLog.NODE_NUM_LOCAL else destNum!! } + .distinctUntilChanged() + .flatMapLatest { logId -> + meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets -> + packets + .mapNotNull { it.toPosition() } + .asFlow() + .distinctUntilChanged { old, new -> + old.time == new.time || + (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) + } + .toList() + } } .stateInWhileSubscribed(initialValue = emptyList()) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 7d90a80f5..0a09214ee 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -85,9 +85,13 @@ fun PositionSection( Spacer(Modifier.height(8.dp)) } - if (!isLocal) { - PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction) - } + PositionActionButtons( + node = node, + isLocal = isLocal, + hasValidPosition = hasValidPosition, + displayUnits = metricsState.displayUnits, + onAction = onAction, + ) if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) { Spacer(Modifier.height(12.dp)) @@ -147,39 +151,44 @@ private fun PositionMap(node: Node, distance: String?) { @Composable private fun PositionActionButtons( node: Node, + isLocal: Boolean, hasValidPosition: Boolean, displayUnits: Config.DisplayConfig.DisplayUnits, onAction: (NodeDetailAction) -> Unit, ) { + if (isLocal && !hasValidPosition) return + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Button( - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, - modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT), - shape = MaterialTheme.shapes.large, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.exchange_position), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Visible, - ) + if (!isLocal) { + Button( + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, + modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT), + shape = MaterialTheme.shapes.large, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.exchange_position), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } } if (hasValidPosition) { FilledTonalButton( onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, - modifier = Modifier.weight(COMPASS_BUTTON_WEIGHT), + modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT), shape = MaterialTheme.shapes.large, ) { Icon(Icons.Rounded.Explore, null, Modifier.size(18.dp)) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 7f9be54c3..edb03ea2c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -23,6 +23,7 @@ import androidx.navigation.toRoute import com.meshtastic.core.strings.getString import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -30,7 +31,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -42,11 +42,11 @@ import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.util.isLora +import org.meshtastic.core.model.util.hasValidEnvironmentMetrics +import org.meshtastic.core.model.util.isDirectSignal import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository @@ -58,14 +58,24 @@ import org.meshtastic.feature.node.metrics.EnvironmentMetricsState import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User import javax.inject.Inject +/** + * UI state for the Node Details screen. + * + * @property node The node being viewed, or null if loading. + * @property ourNode Information about the locally connected node. + * @property metricsState Aggregated sensor and signal metrics. + * @property environmentState Standardized environmental sensor data. + * @property availableLogs A set of log types available for this node. + * @property lastTracerouteTime Timestamp of the last successful traceroute request. + * @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request. + */ data class NodeDetailUiState( val node: Node? = null, val ourNode: Node? = null, @@ -76,6 +86,9 @@ data class NodeDetailUiState( val lastRequestNeighborsTime: Long? = null, ) +/** + * ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration. + */ @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class NodeDetailViewModel @@ -102,182 +115,171 @@ constructor( combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute } .distinctUntilChanged() - private val ourNodeNumFlow = nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() }.distinctUntilChanged() - + /** Primary UI state stream, combining identity, metrics, and global device metadata. */ val uiState: StateFlow = activeNodeId .flatMapLatest { nodeId -> if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) - - val nodeFlow = nodeRepository.nodeDBbyNum.map { it[nodeId] }.distinctUntilChanged() - val telemetryFlow = meshLogRepository.getTelemetryFrom(nodeId).distinctUntilChanged() - val packetsFlow = meshLogRepository.getMeshPacketsFrom(nodeId).distinctUntilChanged() - val posPacketsFlow = - meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value).distinctUntilChanged() - val paxLogsFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value).distinctUntilChanged() - val trReqsFlow = - meshLogRepository - .getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value) - .map { logs -> - logs.filter { log -> - val pkt = log.fromRadio.packet - val decoded = pkt?.decoded - pkt != null && - decoded != null && - decoded.want_response == true && - pkt.from == 0 && - pkt.to == nodeId - } - } - .distinctUntilChanged() - val trResFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value).distinctUntilChanged() - val niReqsFlow = - meshLogRepository - .getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value) - .map { logs -> - logs.filter { log -> - val pkt = log.fromRadio.packet - val decoded = pkt?.decoded - pkt != null && - decoded != null && - decoded.want_response == true && - pkt.from == 0 && - pkt.to == nodeId - } - } - .distinctUntilChanged() - val niResFlow = - meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value).distinctUntilChanged() - - combine( - nodeRepository.ourNodeInfo, - ourNodeNumFlow, - nodeFlow, - nodeRepository.myNodeInfo, - radioConfigRepository.deviceProfileFlow, - telemetryFlow, - packetsFlow, - posPacketsFlow, - paxLogsFlow, - trReqsFlow, - trResFlow, - niReqsFlow, - niResFlow, - meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(), - firmwareReleaseRepository.stableRelease, - firmwareReleaseRepository.alphaRelease, - nodeRequestActions.lastTracerouteTimes, - nodeRequestActions.lastRequestNeighborTimes, - ) { args -> - @Suppress("UNCHECKED_CAST") - NodeDetailUiStateData( - nodeId = nodeId, - actualNode = (args[2] as Node?) ?: createFallbackNode(nodeId), - ourNode = args[0] as Node?, - ourNodeNum = args[1] as Int?, - myInfo = (args[3] as MyNodeEntity?)?.toMyNodeInfo(), - profile = args[4] as org.meshtastic.proto.DeviceProfile, - telemetry = args[5] as List, - packets = args[6] as List, - positionPackets = args[7] as List, - paxLogs = args[8] as List, - tracerouteRequests = args[9] as List, - tracerouteResults = args[10] as List, - neighborInfoRequests = args[11] as List, - neighborInfoResults = args[12] as List, - firmwareEditionArg = args[13] as? FirmwareEdition, - stable = args[14] as FirmwareRelease?, - alpha = args[15] as FirmwareRelease?, - lastTracerouteTime = (args[16] as Map)[nodeId], - lastRequestNeighborsTime = (args[17] as Map)[nodeId], - ) - } - .flatMapLatest { data -> - val pioEnv = if (data.nodeId == data.ourNodeNum) data.myInfo?.pioEnv else null - val hwModel = data.actualNode.user.hw_model?.value ?: 0 - flow { - val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, pioEnv).getOrNull() - - val moduleConfig = data.profile.module_config - val displayUnits = data.profile.config?.display?.units - - val metricsState = - MetricsState( - node = data.actualNode, - isLocal = data.nodeId == data.ourNodeNum, - deviceHardware = hw, - reportedTarget = pioEnv, - isManaged = data.profile.config?.security?.is_managed ?: false, - isFahrenheit = - moduleConfig?.telemetry?.environment_display_fahrenheit == true || - (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL), - displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC, - deviceMetrics = data.telemetry.filter { it.device_metrics != null }, - powerMetrics = data.telemetry.filter { it.power_metrics != null }, - hostMetrics = data.telemetry.filter { it.host_metrics != null }, - signalMetrics = - data.packets.filter { pkt -> - (pkt.rx_time ?: 0) > 0 && - pkt.hop_start == pkt.hop_limit && - pkt.via_mqtt != true && - pkt.isLora() - }, - positionLogs = data.positionPackets.mapNotNull { it.toPosition() }, - paxMetrics = data.paxLogs, - tracerouteRequests = data.tracerouteRequests, - tracerouteResults = data.tracerouteResults, - neighborInfoRequests = data.neighborInfoRequests, - neighborInfoResults = data.neighborInfoResults, - firmwareEdition = data.firmwareEditionArg, - latestStableFirmware = data.stable ?: FirmwareRelease(), - latestAlphaFirmware = data.alpha ?: FirmwareRelease(), - ) - - val environmentState = - EnvironmentMetricsState( - environmentMetrics = - data.telemetry.filter { - val em = it.environment_metrics - em != null && - em.relative_humidity != null && - em.temperature != null && - em.temperature!!.isNaN().not() - }, - ) - - val availableLogs = buildSet { - if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE) - if (metricsState.hasPositionLogs()) { - add(LogsType.NODE_MAP) - add(LogsType.POSITIONS) - } - if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) - if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL) - if (metricsState.hasPowerMetrics()) add(LogsType.POWER) - if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE) - if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO) - if (metricsState.hasHostMetrics()) add(LogsType.HOST) - if (metricsState.hasPaxMetrics()) add(LogsType.PAX) - } - - emit( - NodeDetailUiState( - node = metricsState.node, - ourNode = data.ourNode, - metricsState = metricsState, - environmentState = environmentState, - availableLogs = availableLogs, - lastTracerouteTime = data.lastTracerouteTime, - lastRequestNeighborsTime = data.lastRequestNeighborsTime, - ), - ) - } - } + buildUiStateFlow(nodeId) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState()) + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun buildUiStateFlow(nodeId: Int): Flow { + val nodeFlow = + nodeRepository.nodeDBbyNum + .map { it[nodeId] ?: Node.createFallback(nodeId, getString(Res.string.fallback_node_name)) } + .distinctUntilChanged() + + // 1. Logs & Metrics Data (fetches telemetry, packets, paxcount, and response history) + val metricsLogsFlow = + combine( + meshLogRepository.getTelemetryFrom(nodeId), + meshLogRepository.getMeshPacketsFrom(nodeId), + meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value), + meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value), + meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value), + meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value), + ) { args: Array> -> + @Suppress("UNCHECKED_CAST") + LogsGroup( + telemetry = args[0] as List, + packets = args[1] as List, + posPackets = args[2] as List, + pax = args[3] as List, + trRes = args[4] as List, + niRes = args[5] as List, + ) + } + + // 2. Identity & Config (local device info and radio profile) + val identityFlow = + combine(nodeRepository.ourNodeInfo, nodeRepository.myNodeInfo, radioConfigRepository.deviceProfileFlow) { + ourNode, + myInfo, + profile, + -> + IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile) + } + + // 3. Metadata & Request Timestamps (firmware versions and last request times) + val metadataFlow = + combine( + meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(), + firmwareReleaseRepository.stableRelease, + firmwareReleaseRepository.alphaRelease, + nodeRequestActions.lastTracerouteTimes.map { it[nodeId] }, + nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] }, + ) { args: Array -> + MetadataGroup( + edition = args[0] as? FirmwareEdition, + stable = args[1] as? FirmwareRelease, + alpha = args[2] as? FirmwareRelease, + trTime = args[3] as? Long, + niTime = args[4] as? Long, + ) + } + + // 4. Requests History (tracking traceroute and neighbor info requests sent from this device) + val requestsFlow = + combine( + meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP), + meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP), + ) { trReqs, niReqs -> + trReqs to niReqs + } + + // Assemble final UI state + return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) { + node, + logs, + identity, + metadata, + requests, + -> + val (trReqs, niReqs) = requests + val isLocal = node.num == identity.ourNode?.num + val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null + val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull() + + val moduleConfig = identity.profile.module_config + val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC + + val metricsState = + MetricsState( + node = node, + isLocal = isLocal, + deviceHardware = hw, + reportedTarget = pioEnv, + isManaged = identity.profile.config?.security?.is_managed ?: false, + isFahrenheit = + moduleConfig?.telemetry?.environment_display_fahrenheit == true || + (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL), + displayUnits = displayUnits, + deviceMetrics = logs.telemetry.filter { it.device_metrics != null }, + powerMetrics = logs.telemetry.filter { it.power_metrics != null }, + hostMetrics = logs.telemetry.filter { it.host_metrics != null }, + signalMetrics = logs.packets.filter { it.isDirectSignal() }, + positionLogs = logs.posPackets.mapNotNull { it.toPosition() }, + paxMetrics = logs.pax, + tracerouteRequests = trReqs, + tracerouteResults = logs.trRes, + neighborInfoRequests = niReqs, + neighborInfoResults = logs.niRes, + firmwareEdition = metadata.edition, + latestStableFirmware = metadata.stable ?: FirmwareRelease(), + latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(), + ) + + val environmentState = + EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() }) + + val availableLogs = buildSet { + if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE) + if (metricsState.hasPositionLogs()) { + add(LogsType.NODE_MAP) + add(LogsType.POSITIONS) + } + if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) + if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL) + if (metricsState.hasPowerMetrics()) add(LogsType.POWER) + if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE) + if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO) + if (metricsState.hasHostMetrics()) add(LogsType.HOST) + if (metricsState.hasPaxMetrics()) add(LogsType.PAX) + } + + NodeDetailUiState( + node = node, + ourNode = identity.ourNode, + metricsState = metricsState, + environmentState = environmentState, + availableLogs = availableLogs, + lastTracerouteTime = metadata.trTime, + lastRequestNeighborsTime = metadata.niTime, + ) + } + } + + private data class LogsGroup( + val telemetry: List, + val packets: List, + val posPackets: List, + val pax: List, + val trRes: List, + val niRes: List, + ) + + private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile) + + private data class MetadataGroup( + val edition: FirmwareEdition?, + val stable: FirmwareRelease?, + val alpha: FirmwareRelease?, + val trTime: Long?, + val niTime: Long?, + ) + val effects: SharedFlow = nodeRequestActions.effects fun start(nodeId: Int) { @@ -286,6 +288,7 @@ constructor( } } + /** Dispatches high-level node management actions like removal, muting, or favoriting. */ fun handleNodeMenuAction(action: NodeMenuAction) { when (action) { is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node) @@ -321,41 +324,10 @@ constructor( nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes) } + /** Returns the type-safe navigation route for a direct message to this node. */ fun getDirectMessageRoute(node: Node, ourNode: Node?): String { val hasPKC = ourNode?.hasPKC == true val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel return "${channel}${node.user.id}" } - - @Suppress("MagicNumber") - private suspend fun createFallbackNode(nodeNum: Int): Node { - val userId = DataPacket.nodeNumToDefaultId(nodeNum) - val safeUserId = userId.padStart(4, '0').takeLast(4) - val longName = "${getString(Res.string.fallback_node_name)}_$safeUserId" - val defaultUser = - User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET) - return Node(num = nodeNum, user = defaultUser) - } } - -private data class NodeDetailUiStateData( - val nodeId: Int, - val actualNode: Node, - val ourNode: Node?, - val ourNodeNum: Int?, - val myInfo: MyNodeInfo?, - val profile: org.meshtastic.proto.DeviceProfile, - val telemetry: List, - val packets: List, - val positionPackets: List, - val paxLogs: List, - val tracerouteRequests: List, - val tracerouteResults: List, - val neighborInfoRequests: List, - val neighborInfoResults: List, - val firmwareEditionArg: FirmwareEdition?, - val stable: FirmwareRelease?, - val alpha: FirmwareRelease?, - val lastTracerouteTime: Long?, - val lastRequestNeighborsTime: Long?, -) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 7f61d91c5..14fb42e8a 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.update @@ -52,10 +53,10 @@ import org.meshtastic.core.data.repository.TracerouteSnapshotRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.util.UnitConversions +import org.meshtastic.core.model.util.hasValidEnvironmentMetrics import org.meshtastic.core.model.util.nowSeconds import org.meshtastic.core.model.util.toDate import org.meshtastic.core.model.util.toInstant @@ -76,11 +77,9 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.Config -import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User import java.io.BufferedWriter import java.io.FileNotFoundException import java.io.FileWriter @@ -90,15 +89,11 @@ import java.util.Locale import javax.inject.Inject import org.meshtastic.proto.Paxcount as ProtoPaxcount -private const val DEFAULT_ID_SUFFIX_LENGTH = 4 - -private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && ((rx_snr ?: 0f) != 0f || (rx_rssi ?: 0) != 0) - -private fun Telemetry.hasValidEnvironmentMetrics(): Boolean { - val metrics = this.environment_metrics ?: return false - return metrics.relative_humidity != null && metrics.temperature != null && metrics.temperature?.isNaN() != true -} +private fun MeshPacket.hasValidSignal(): Boolean = rx_time > 0 && (rx_snr != 0f || rx_rssi != 0) +/** + * ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node. + */ @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class MetricsViewModel @@ -124,30 +119,11 @@ constructor( private val tracerouteOverlayCache = MutableStateFlow>(emptyMap()) - private fun MeshLog.hasValidTraceroute(dest: Int?): Boolean = - with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest } - - private fun MeshLog.hasValidNeighborInfo(dest: Int?): Boolean = - with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest } - - /** - * Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from - * freezing when viewing unknown nodes. - */ - private suspend fun createFallbackNode(nodeNum: Int): Node { - val userId = DataPacket.nodeNumToDefaultId(nodeNum) - val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH) - val longName = getString(Res.string.fallback_node_name) + " $safeUserId" - val defaultUser = - User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET) - - return Node(num = nodeNum, user = defaultUser) - } - fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + /** Returns the map overlay for a specific traceroute request ID. */ fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] if (cached != null) return cached @@ -176,7 +152,9 @@ constructor( fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() fun positionedNodeNums(): Set = - nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet() + nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.numSet() + + private fun List.numSet(): Set = map { it.num }.toSet() init { viewModelScope.launch { @@ -199,13 +177,18 @@ constructor( } private val _state = MutableStateFlow(MetricsState.Empty) + + /** Current aggregated metrics state, including signal history and sensor logs. */ val state: StateFlow = _state private val environmentState = MutableStateFlow(EnvironmentMetricsState()) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) + + /** The active time window for filtering graphed data. */ val timeFrame: StateFlow = _timeFrame + /** Returns the list of time frames that are actually available based on the oldest data point. */ val availableTimeFrames: StateFlow> = combine(_state, environmentState) { state, envState -> val stateOldest = state.oldestTimestampSeconds() @@ -295,6 +278,7 @@ constructor( ) } + /** Shows the detail dialog for a traceroute result, with an option to view on the map. */ fun showTracerouteDetail( annotatedMessage: AnnotatedString, requestId: Int, @@ -352,6 +336,8 @@ constructor( jobs = viewModelScope.launch { if (currentDestNum != null) { + val logNodeIdFlow = nodeRepository.effectiveLogNodeId(currentDestNum) + launch { combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { nodes, myInfo -> nodes[currentDestNum] to (nodes.keys.firstOrNull() to myInfo) @@ -360,7 +346,9 @@ constructor( .collect { (node, localData) -> val (ourNodeNum, myInfo) = localData // Create a fallback node if not found in database (for hidden clients, etc.) - val actualNode = node ?: createFallbackNode(currentDestNum) + val actualNode = + node + ?: Node.createFallback(currentDestNum, getString(Res.string.fallback_node_name)) val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null val hwModel = actualNode.user.hw_model.value val deviceHardware = @@ -394,44 +382,47 @@ constructor( } launch { - meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry -> - val device = mutableListOf() - val power = mutableListOf() - val host = mutableListOf() - val env = mutableListOf() + logNodeIdFlow + .flatMapLatest { meshLogRepository.getTelemetryFrom(it) } + .collect { telemetry -> + val device = mutableListOf() + val power = mutableListOf() + val host = mutableListOf() + val env = mutableListOf() - for (item in telemetry) { - if (item.device_metrics != null) device.add(item) - if (item.power_metrics != null) power.add(item) - if (item.host_metrics != null) host.add(item) - if (item.hasValidEnvironmentMetrics()) env.add(item) - } + for (item in telemetry) { + if (item.device_metrics != null) device.add(item) + if (item.power_metrics != null) power.add(item) + if (item.host_metrics != null) host.add(item) + if (item.hasValidEnvironmentMetrics()) env.add(item) + } - _state.update { state -> - state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host) + _state.update { state -> + state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host) + } + environmentState.update { it.copy(environmentMetrics = env) } } - environmentState.update { it.copy(environmentMetrics = env) } - } } launch { - meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets -> - _state.update { state -> - state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + logNodeIdFlow + .flatMapLatest { meshLogRepository.getMeshPacketsFrom(it) } + .collect { meshPackets -> + _state.update { state -> + state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + } } - } } launch { combine( - meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value), - meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP.value), + meshLogRepository.getRequestLogs(currentDestNum, PortNum.TRACEROUTE_APP), + logNodeIdFlow.flatMapLatest { + meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value) + }, ) { request, response -> _state.update { state -> - state.copy( - tracerouteRequests = request.filter { it.hasValidTraceroute(currentDestNum) }, - tracerouteResults = response, - ) + state.copy(tracerouteRequests = request, tracerouteResults = response) } } .collect {} @@ -439,42 +430,39 @@ constructor( launch { combine( - meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value), - meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP.value), + meshLogRepository.getRequestLogs(currentDestNum, PortNum.NEIGHBORINFO_APP), + logNodeIdFlow.flatMapLatest { + meshLogRepository.getLogsFrom(it, PortNum.NEIGHBORINFO_APP.value) + }, ) { request, response -> _state.update { state -> - state.copy( - neighborInfoRequests = - request.filter { it.hasValidNeighborInfo(currentDestNum) }, - neighborInfoResults = response, - ) + state.copy(neighborInfoRequests = request, neighborInfoResults = response) } } .collect {} } launch { - meshLogRepository.getMeshPacketsFrom( - currentDestNum, - PortNum.POSITION_APP.value, - ).collect { packets -> - val distinctPositions = - packets - .mapNotNull { it.toPosition() } - .asFlow() - .distinctUntilChanged { old, new -> - old.time == new.time || - (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) - } - .toList() - _state.update { state -> state.copy(positionLogs = distinctPositions) } - } + logNodeIdFlow + .flatMapLatest { meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value) } + .collect { packets -> + val distinctPositions = + packets + .mapNotNull { it.toPosition() } + .asFlow() + .distinctUntilChanged { old, new -> + old.time == new.time || + (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) + } + .toList() + _state.update { state -> state.copy(positionLogs = distinctPositions) } + } } launch { - meshLogRepository.getLogsFrom(currentDestNum, PortNum.PAXCOUNTER_APP.value).collect { logs -> - _state.update { state -> state.copy(paxMetrics = logs) } - } + logNodeIdFlow + .flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.PAXCOUNTER_APP.value) } + .collect { logs -> _state.update { state -> state.copy(paxMetrics = logs) } } } launch { @@ -558,13 +546,15 @@ constructor( val packet = log.fromRadio.packet val decoded = packet?.decoded if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { + // Requests for paxcount (want_response = true) should not be logged as data points. + if (decoded.want_response == true) return null val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax } } catch (e: IOException) { Logger.e(e) { "Failed to parse Paxcount from binary data" } } - // Fallback: Try direct base64 or bytes from raw_message + // Fallback: Attempt to parse Paxcount from raw_message as base64 or hex string. try { val base64 = log.raw_message.trim() if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {