feat: Accurately display outgoing diagnostic packets (#4569)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-16 16:09:21 -06:00
committed by GitHub
parent 6a244316b2
commit c690ddc7ea
18 changed files with 922 additions and 381 deletions

View File

@@ -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),
)

View File

@@ -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),
)

View File

@@ -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 }) }
}
}

View File

@@ -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 }) }
}
}

View File

@@ -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<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItems) }.flowOn(dispatchers.io).conflate()
fun getAllLogsUnbounded(): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getAllLogs(Int.MAX_VALUE) }
.flowOn(dispatchers.io)
.conflate()
fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> = 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<List<MeshLog>> =
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<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database without any limit. */
fun getAllLogsUnbounded(): Flow<List<MeshLog>> = getAllLogs(Int.MAX_VALUE)
/** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>> = 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<List<MeshPacket>> =
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<List<Telemetry>> = 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<List<MeshLog>> = 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<List<Telemetry>> = 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<Int> = 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<List<MeshLog>> = 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<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum)
.mapLatest { list -> list.mapNotNull { it.fromRadio.packet } }
.flowOn(dispatchers.io)
fun getMyNodeInfo(): Flow<MyNodeInfo?> = getLogsFrom(0, 0)
/** Returns the cached [MyNodeInfo] from the system logs. */
fun getMyNodeInfo(): Flow<MyNodeInfo?> = 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
}
}

View File

@@ -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<MyNodeEntity?> =
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
// our node info
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
/** Information about the locally connected node, as seen from the mesh. */
val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
// The unique userId of our node
private val _myId = MutableStateFlow<String?>(null)
/** The unique userId (hex string) of our local node. */
val myId: StateFlow<String?>
get() = _myId
// A map from nodeNum to Node
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
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<Int> = 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<NodeEntity>) =
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<Int>) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) }
@@ -172,9 +195,11 @@ constructor(
suspend fun getUnknownNodes(): List<NodeEntity> =
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<Int> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
@@ -182,6 +207,7 @@ constructor(
.flowOn(dispatchers.io)
.conflate()
/** Flow emitting the total number of nodes in the database. */
val totalNodeCount: Flow<Int> =
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) }
}

View File

@@ -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<MyNodeEntity>()
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<MyNodeEntity>()
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) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<MyNodeEntity?>(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(),
)
}
}

View File

@@ -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
""",
)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -14,11 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}

View File

@@ -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<List<Position>> =
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())

View File

@@ -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))

View File

@@ -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<NodeDetailUiState> =
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<Telemetry>,
packets = args[6] as List<MeshPacket>,
positionPackets = args[7] as List<MeshPacket>,
paxLogs = args[8] as List<MeshLog>,
tracerouteRequests = args[9] as List<MeshLog>,
tracerouteResults = args[10] as List<MeshLog>,
neighborInfoRequests = args[11] as List<MeshLog>,
neighborInfoResults = args[12] as List<MeshLog>,
firmwareEditionArg = args[13] as? FirmwareEdition,
stable = args[14] as FirmwareRelease?,
alpha = args[15] as FirmwareRelease?,
lastTracerouteTime = (args[16] as Map<Int, Long>)[nodeId],
lastRequestNeighborsTime = (args[17] as Map<Int, Long>)[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<NodeDetailUiState> {
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<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 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<Any?> ->
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<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
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<NodeRequestEffect> = 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<Telemetry>,
val packets: List<MeshPacket>,
val positionPackets: List<MeshPacket>,
val paxLogs: List<MeshLog>,
val tracerouteRequests: List<MeshLog>,
val tracerouteResults: List<MeshLog>,
val neighborInfoRequests: List<MeshLog>,
val neighborInfoResults: List<MeshLog>,
val firmwareEditionArg: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val lastTracerouteTime: Long?,
val lastRequestNeighborsTime: Long?,
)

View File

@@ -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<Map<Int, TracerouteOverlay>>(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<Int> =
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.numSet()
private fun List<Node>.numSet(): Set<Int> = 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<MetricsState> = _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> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
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<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
logNodeIdFlow
.flatMapLatest { meshLogRepository.getTelemetryFrom(it) }
.collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
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]+$"))) {