diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt index 0498f618a1..3bd740533a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt @@ -137,6 +137,14 @@ open class MeshLogRepositoryImpl( .distinctUntilChanged() .conflate() + override fun getInboundRequestLogs(portNum: PortNum): Flow> = dbManager + .observeCurrentDb { it.meshLogDao().getLogsOnPort(portNum.value, MeshLog.NODE_NUM_LOCAL, DEFAULT_MAX_LOGS) } + .map { list -> + list.map { it.asExternalModel() }.filter { it.fromRadio.packet?.decoded?.want_response == true } + } + .distinctUntilChanged() + .flowOn(dispatchers.io) + private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { val decoded = log.fromRadio.packet?.decoded ?: return@runCatching null // Requests for telemetry (want_response = true) should not be logged as data points. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 8c93080c0d..9c96e5590b 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -66,6 +66,16 @@ interface MeshLogDao { @Query(LOGS_FROM_QUERY) fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> + /** Retrieves [MeshLog]s on 'port_num' (PortNum) from any node except [excludeFromNum]. */ + @Query( + """ + SELECT * FROM log + WHERE port_num = :portNum AND from_num != :excludeFromNum + ORDER BY received_date DESC LIMIT :maxItem + """, + ) + fun getLogsOnPort(portNum: Int, excludeFromNum: Int, maxItem: Int): Flow> + @Insert suspend fun insert(log: MeshLog) /** diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt index 832dedb0bf..259c2f74ac 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt @@ -122,6 +122,19 @@ class MeshLogDaoTest { assertEquals(1, survivorStillPresent.size, "unselected log from a different fromNum should remain") } + @Test + fun testGetLogsOnPortSpansNodesAndSkipsExcludedNode() = runTest { + val traceroute = PortNum.TRACEROUTE_APP.value + meshLogDao.insert(logEntry("ours", portNum = traceroute, time = 100).copy(fromNum = 0)) + meshLogDao.insert(logEntry("node-a", portNum = traceroute, time = 200)) + meshLogDao.insert(logEntry("node-b", portNum = traceroute, time = 300).copy(fromNum = 999)) + meshLogDao.insert(logEntry("other-port", time = 400)) + + val logs = meshLogDao.getLogsOnPort(traceroute, excludeFromNum = 0, maxItem = Int.MAX_VALUE).first() + + assertEquals(listOf("node-b", "node-a"), logs.map { it.uuid }) + } + @Test fun testDeleteLogsByUuidAtomicRollsBackEarlierChunkWhenLaterChunkFails() = runTest { val firstUuid = "first-chunk" diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt index 5fb6667549..76fa29af04 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt @@ -61,6 +61,9 @@ interface MeshLogRepository { */ fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow> + /** Retrieves the request logs other nodes sent on [portNum], where `want_response` is true. */ + fun getInboundRequestLogs(portNum: PortNum): Flow> + /** Returns the cached [MyNodeInfo] from the system logs. */ fun getMyNodeInfo(): Flow diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt index aca7d5e378..03c25bb2ec 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshLogRepository.kt @@ -88,6 +88,14 @@ class FakeMeshLogRepository : } } + override fun getInboundRequestLogs(portNum: PortNum): Flow> = logsFlow.map { + it.filter { log -> + log.fromNum != MeshLog.NODE_NUM_LOCAL && + log.portNum == portNum.value && + log.fromRadio.packet?.decoded?.want_response == true + } + } + override fun getMyNodeInfo(): Flow = MutableStateFlow(null) override suspend fun insert(log: MeshLog) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index fd1143422a..3511538494 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -154,7 +154,7 @@ private fun rememberTelemetricFeatures( TelemetricFeature( titleRes = LogsType.TRACEROUTE.titleRes, icon = LogsType.TRACEROUTE.icon, - requestAction = { NodeMenuAction.TraceRoute(it) }, + requestAction = if (isLocal) null else { n -> NodeMenuAction.TraceRoute(n) }, logsType = LogsType.TRACEROUTE, cooldownTimestamp = lastTracerouteTime, isVisible = { !isLocal }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index f4c2e1fdb6..f5009873d2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transformLatest @@ -105,6 +106,12 @@ constructor( meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart { emit(emptyList()) }, + // Traceroutes other nodes ran to us are logged under each tracer, so our own page gathers them all. + if (effectiveNodeId == MeshLog.NODE_NUM_LOCAL) { + meshLogRepository.getInboundRequestLogs(PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) } + } else { + flowOf(emptyList()) + }, ) { args: Array> -> @Suppress("UNCHECKED_CAST") LogsGroup( @@ -114,6 +121,7 @@ constructor( pax = args[3] as List, trRes = args[4] as List, niRes = args[5] as List, + trInbound = args[6] as List, ) } @@ -234,7 +242,8 @@ constructor( tracerouteRequests = trReqs, tracerouteResults = logs.trRes, tracerouteObserved = observedTracerouteResponses(trReqs, logs.trRes), - tracerouteTracedBy = tracedByTracerouteRequests(logs.trRes, identity.ourNode?.num), + tracerouteTracedBy = + tracedByTracerouteRequests(if (isLocal) logs.trInbound else logs.trRes, identity.ourNode?.num), neighborInfoRequests = niReqs, neighborInfoResults = logs.niRes, firmwareEdition = metadata.edition, @@ -289,6 +298,7 @@ constructor( val pax: List, val trRes: List, val niRes: List, + val trInbound: List, ) private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt index aad87c0599..a37ab95469 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt @@ -60,7 +60,8 @@ class TracerouteHistoryUseCaseTest { // The fake maps every node to the local log id; the real repository does that only for our own node. private val nodeRepository = object : NodeRepository by fakeNodeRepository { - override fun effectiveLogNodeId(nodeNum: Int): Flow = flowOf(nodeNum) + override fun effectiveLogNodeId(nodeNum: Int): Flow = + flowOf(if (nodeNum == OUR_NODE) MeshLog.NODE_NUM_LOCAL else nodeNum) } private val meshLogRepository = FakeMeshLogRepository() private val nodeRequestActions = @@ -148,6 +149,27 @@ class TracerouteHistoryUseCaseTest { assertFalse(metrics.hasTracerouteLogs()) } + @Test + fun `our own history gathers the traces every node ran to us`() = runTest { + meshLogRepository.setLogs( + listOf( + inboundRequest(from = NODE_X, to = OUR_NODE, receivedDate = 2_000L), + inboundRequest(from = OTHER_NODE, to = OUR_NODE, receivedDate = 3_000L), + inboundRequest(from = NODE_X, to = OTHER_NODE, receivedDate = 4_000L), + ), + ) + + val state = + useCase(OUR_NODE).first { + it.metricsState.node?.num == OUR_NODE && it.metricsState.tracerouteTracedBy.isNotEmpty() + } + val history = state.metricsState.history() + + assertTrue(history.all { it.kind == TracerouteKind.TRACED_BY }) + assertEquals(listOf(OTHER_NODE, NODE_X), history.map { it.result?.fromRadio?.packet?.from }) + assertTrue(LogsType.TRACEROUTE in state.availableLogs) + } + private fun log(uuid: String, fromNum: Int, receivedDate: Long, packet: MeshPacket) = MeshLog( uuid = uuid, message_type = "Packet",