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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 922 additions and 381 deletions

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(),
)
}
}