mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
feat: Accurately display outgoing diagnostic packets (#4569)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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]+$"))) {
|
||||
|
||||
Reference in New Issue
Block a user