Refactor command handling, enhance tests, and improve discovery logic (#4878)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-22 00:42:27 -05:00
committed by GitHub
parent d136b162a4
commit c38bfc64de
76 changed files with 2220 additions and 1277 deletions

View File

@@ -34,9 +34,11 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -57,18 +59,16 @@ class CommandSenderImpl(
private val packetHandler: PacketHandler,
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
override var lastNeighborInfo: NeighborInfo? = null
// We'll need a way to track connection state in shared code,
// maybe via ServiceRepository or similar.
// For now I'll assume it's injected or available.
@@ -251,7 +251,7 @@ class CommandSenderImpl(
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis
tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
@@ -302,11 +302,11 @@ class CommandSenderImpl(
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
lastNeighborInfo
neighborInfoHandler.lastNeighborInfo
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }

View File

@@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
@@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl(
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L
override fun start(scope: CoroutineScope) {
@@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl(
override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@@ -253,13 +254,13 @@ class MeshConnectionManagerImpl(
}
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) }
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
startHandshakeStallGuard(1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
action()
}
@@ -340,8 +341,6 @@ class MeshConnectionManagerImpl(
}
companion object {
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 30.seconds

View File

@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
@@ -37,12 +35,10 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
@@ -59,6 +55,7 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
@@ -75,8 +72,6 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
@@ -107,17 +102,21 @@ class MeshDataHandlerImpl(
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
private val historyManager: HistoryManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
private val storeForwardHandler: StoreForwardPacketHandler,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
override fun start(scope: CoroutineScope) {
this.scope = scope
storeForwardHandler.start(scope)
}
private val rememberDataType =
@@ -191,11 +190,11 @@ class MeshDataHandlerImpl(
}
PortNum.STORE_FORWARD_APP -> {
handleStoreAndForward(packet, dataPacket, myNodeNum)
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
}
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
handleStoreForwardPlusPlus(packet)
storeForwardHandler.handleStoreForwardPlusPlus(packet)
}
PortNum.ADMIN_APP -> {
@@ -235,98 +234,6 @@ class MeshDataHandlerImpl(
rememberDataPacket(u, myNodeNum)
}
private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
// If it has a commit hash, it's already on the chain (Confirmed)
// Otherwise it's still being routed via SF++ (Routing)
val status =
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
// Prefer a full 16-byte hash calculated from the message bytes if available
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository.value.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
private fun handlePaxCounter(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
@@ -559,52 +466,6 @@ class MeshDataHandlerImpl(
}
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
// For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
// In the original, it was used for logging.
val h = s.history
val lastRequest = h?.last_request ?: 0
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
}
h != null -> {
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
// historyManager call remains same
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
else -> {}
}
}
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
@@ -807,7 +668,5 @@ class MeshDataHandlerImpl(
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
}
}

View File

@@ -17,13 +17,15 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo
class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override var lastNeighborInfo: NeighborInfo? = null
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
@@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl(
// Store the last neighbor info from our connected radio
val from = packet.from
if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni
lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}
@@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl(
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = commandSender.neighborInfoStartTimes.remove(requestId)
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val neighbors =
ni.neighbors.joinToString("\n") { n ->

View File

@@ -0,0 +1,189 @@
/*
* 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.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import kotlin.time.Duration.Companion.milliseconds
/** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */
@Single
class StoreForwardPacketHandlerImpl(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
) : StoreForwardPacketHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
override fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> handleLinkProvide(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository.value.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
val h = s.history
val lastRequest = h?.last_request ?: 0
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
dataHandler.value.rememberDataPacket(u, myNodeNum)
}
h != null -> {
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
dataHandler.value.rememberDataPacket(u, myNodeNum)
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
dataHandler.value.rememberDataPacket(u, myNodeNum)
}
else -> {}
}
}
}

View File

@@ -17,18 +17,20 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -42,33 +44,43 @@ class TracerouteHandlerImpl(
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val startTimes = atomic(persistentMapOf<Int, Long>())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
// Decode the route discovery once — avoids triple protobuf decode
val routeDiscovery = packet.fullRouteDiscovery ?: return
val forwardRoute = routeDiscovery.route
val returnRoute = routeDiscovery.route_back
// Require both directions for a "full" traceroute response
if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return
val full =
packet.getFullTracerouteResponse(
routeDiscovery.getTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
"${node.user.long_name} (${node.user.short_name})"
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
?: "Unknown" // TODO: Use core:resources once available in core:data
},
headerTowards = "Route towards destination:",
headerBack = "Route back to us:",
) ?: return
)
val requestId = packet.decoded?.request_id ?: 0
if (logUuid != null) {
scope.handledLaunch {
logInsertJob?.join()
val routeDiscovery = packet.fullRouteDiscovery
val forwardRoute = routeDiscovery?.route.orEmpty()
val returnRoute = routeDiscovery?.route_back.orEmpty()
val routeNodeNums = (forwardRoute + returnRoute).distinct()
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
val snapshotPositions =
@@ -77,28 +89,27 @@ class TracerouteHandlerImpl(
}
}
val start = commandSender.tracerouteStartTimes.remove(requestId)
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s"
"$full\n\n$durationText"
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
full
}
val routeDiscovery = packet.fullRouteDiscovery
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.route.orEmpty(),
returnRoute = routeDiscovery?.route_back.orEmpty(),
forwardRoute = forwardRoute,
returnRoute = returnRoute,
logUuid = logUuid,
),
)

View File

@@ -16,15 +16,47 @@
*/
package org.meshtastic.core.data.manager
class FromRadioPacketHandlerImplTest {
/*
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.QueueStatus
import kotlin.test.BeforeTest
import kotlin.test.Test
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
class FromRadioPacketHandlerImplTest {
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val router: MeshRouter = mock(MockMode.autofill)
private lateinit var handler: FromRadioPacketHandlerImpl
@Before
@BeforeTest
fun setup() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
handler =
FromRadioPacketHandlerImpl(
@@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleMyInfo(myInfo) }
verify { configFlowManager.handleMyInfo(myInfo) }
}
@Test
@@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleLocalMetadata(metadata) }
verify { configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
val nodeInfo = NodeInfo(num = 1234)
val nodeInfo = ProtoNodeInfo(num = 1234)
val proto = FromRadio(node_info = nodeInfo)
every { router.configFlowManager.newNodeCount } returns 1
every { configFlowManager.newNodeCount } returns 1
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
verify { configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
}
@@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleConfigComplete(nonce) }
verify { configFlowManager.handleConfigComplete(nonce) }
}
@Test
@@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest {
handler.handleFromRadio(proto)
verify { router.configHandler.handleDeviceConfig(config) }
verify { configHandler.handleDeviceConfig(config) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val proto = FromRadio(moduleConfig = moduleConfig)
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { packetHandler.removeResponse(0, complete = false) }
verify { configHandler.handleModuleConfig(moduleConfig) }
}
*/
@Test
fun `handleFromRadio routes CHANNEL to configHandler`() {
val channel = Channel(index = 0)
val proto = FromRadio(channel = channel)
handler.handleFromRadio(proto)
verify { configHandler.handleChannel(channel) }
}
@Test
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
val proto = FromRadio(mqttClientProxyMessage = proxyMsg)
handler.handleFromRadio(proto)
verify { mqttManager.handleMqttProxyMessage(proxyMsg) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
// Note: getString() from Compose Resources requires Skiko native lib which
// is not available in headless JVM tests. We test the parts that don't trigger it.
try {
handler.handleFromRadio(proto)
} catch (_: Throwable) {
// Expected: Skiko can't load in headless JVM/native
}
verify { serviceRepository.setClientNotification(notification) }
}
}

View File

@@ -16,9 +16,9 @@
*/
package org.meshtastic.core.data.manager
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.proto.StoreAndForward
import kotlin.test.Test
import kotlin.test.assertEquals
class HistoryManagerImplTest {

View File

@@ -17,10 +17,25 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
@@ -35,12 +50,23 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class MeshDataHandlerTest {
private lateinit var handler: MeshDataHandlerImpl
@@ -56,12 +82,15 @@ class MeshDataHandlerTest {
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val historyManager: HistoryManager = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val messageFilter: MessageFilter = mock(MockMode.autofill)
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@BeforeTest
fun setUp() {
@@ -79,13 +108,21 @@ class MeshDataHandlerTest {
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
commandSender = commandSender,
historyManager = historyManager,
connectionManager = lazy { connectionManager },
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
radioConfigRepository = radioConfigRepository,
messageFilter = messageFilter,
storeForwardHandler = storeForwardHandler,
)
handler.start(testScope)
// Default: mapper returns null for empty packets, which is the safe default
every { dataMapper.toDataPacket(any()) } returns null
// Stub commonly accessed properties to avoid NPE from autofill
every { nodeManager.nodeDBbyID } returns emptyMap()
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
}
@Test
@@ -94,8 +131,582 @@ class MeshDataHandlerTest {
}
@Test
fun `handleReceivedData processes packet`() {
fun `handleReceivedData returns early when dataMapper returns null`() {
val packet = MeshPacket()
every { dataMapper.toDataPacket(packet) } returns null
handler.handleReceivedData(packet, 123)
// Should not broadcast if dataMapper returns null
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData does not broadcast for position from local node`() {
val myNodeNum = 123
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(myNodeNum),
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
// Position from local node: shouldBroadcast stays as !fromUs = false
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData broadcasts for remote packets`() {
val myNodeNum = 123
val remoteNum = 456
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(remoteNum),
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData tracks analytics`() {
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = "!other",
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { analytics.track("num_data_receive", any()) }
}
// --- Position handling ---
@Test
fun `position packet delegates to nodeManager`() {
val myNodeNum = 123
val remoteNum = 456
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
}
// --- NodeInfo handling ---
@Test
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
val myNodeNum = 123
val remoteNum = 456
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
}
@Test
fun `nodeinfo packet from local node is ignored`() {
val myNodeNum = 123
val user = User(id = "!local", long_name = "Local", short_name = "L")
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// --- Paxcounter handling ---
@Test
fun `paxcounter packet delegates to nodeManager`() {
val remoteNum = 456
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = pax.encode().toByteString(),
dataType = PortNum.PAXCOUNTER_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
}
// --- Traceroute handling ---
@Test
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = "!local",
bytes = byteArrayOf().toByteString(),
dataType = PortNum.TRACEROUTE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- NeighborInfo handling ---
@Test
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
val ni = NeighborInfo(node_id = 456)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = ni.encode().toByteString(),
dataType = PortNum.NEIGHBORINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { neighborInfoHandler.handleNeighborInfo(packet) }
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Store-and-Forward handling ---
@Test
fun `store forward packet delegates to storeForwardHandler`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf().toByteString(),
dataType = PortNum.STORE_FORWARD_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
}
// --- Routing/ACK-NAK handling ---
@Test
fun `routing packet with successful ack broadcasts and removes response`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { packetHandler.removeResponse(99, complete = true) }
}
@Test
fun `routing packet always broadcasts`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Telemetry handling ---
@Test
fun `telemetry packet updates node via nodeManager`() {
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { nodeManager.updateNode(456, any(), any(), any()) }
}
@Test
fun `telemetry from local node also updates connectionManager`() {
val myNodeNum = 123
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { connectionManager.updateTelemetry(any()) }
}
// --- Text message handling ---
@Test
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
@Test
fun `duplicate text message is not inserted again`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
// Return existing packet on duplicate check
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
packetRepository.insert(any(), any(), any(), any(), any(), any())
}
}
// --- Reaction handling ---
@Test
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
val emojiBytes = "👍".encodeToByteArray()
val packet =
MeshPacket(
id = 99,
from = 456,
to = 123,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = emojiBytes.toByteString(),
reply_id = 42,
emoji = 1,
),
)
val dataPacket =
DataPacket(
id = 99,
from = "!remote",
to = "!local",
bytes = emojiBytes.toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.nodeDBbyNodeNum } returns
mapOf(
456 to Node(num = 456, user = User(id = "!remote")),
123 to Node(num = 123, user = User(id = "!local")),
)
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
every { nodeManager.myNodeNum } returns 123
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insertReaction(any(), 123) }
}
// --- Range test / detection sensor handling ---
@Test
fun `range test packet is remembered as text message type`() = testScope.runTest {
val packet =
MeshPacket(
id = 55,
from = 456,
decoded =
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 55,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "test".encodeToByteArray().toByteString(),
dataType = PortNum.RANGE_TEST_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Range test should be remembered with TEXT_MESSAGE_APP dataType
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
// --- Admin message handling ---
@Test
fun `admin message sets session passkey`() {
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
val packet =
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = admin.encode().toByteString(),
dataType = PortNum.ADMIN_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { commandSender.setSessionPasskey(any()) }
}
// --- Message filtering ---
@Test
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
val packet =
MeshPacket(
id = 77,
from = 456,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "spam content".encodeToByteArray().toByteString(),
),
)
val dataPacket =
DataPacket(
id = 77,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "spam content".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
every { nodeManager.nodeDBbyID } returns emptyMap()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter("spam content", false) } returns true
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Verify insert was called with filtered = true (6th param)
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
@Test
fun `message from ignored node is filtered`() = testScope.runTest {
val packet =
MeshPacket(
id = 88,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 88,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
every { nodeManager.nodeDBbyID } returns
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
}

View File

@@ -16,22 +16,29 @@
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.FilterPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MessageFilterImplTest {
/*
private lateinit var filterPrefs: FilterPrefs
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean>
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>>
private val filterEnabledFlow = MutableStateFlow(true)
private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
private lateinit var filterService: MessageFilterImpl
@Before
@BeforeTest
fun setup() {
filterEnabledFlow = MutableStateFlow(true)
filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
filterPrefs = mockk {
every { filterEnabled } returns filterEnabledFlow
every { filterWords } returns filterWordsFlow
}
filterPrefs = mock(MockMode.autofill)
every { filterPrefs.filterEnabled } returns filterEnabledFlow
every { filterPrefs.filterWords } returns filterWordsFlow
filterService = MessageFilterImpl(filterPrefs)
}
@@ -92,6 +99,4 @@ class MessageFilterImplTest {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
}
*/
}

View File

@@ -16,17 +16,36 @@
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
/*
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private lateinit var nodeManager: NodeManagerImpl
@Before
@BeforeTest
fun setUp() {
mockkStatic("org.meshtastic.core.resources.GetStringKt")
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
}
@@ -63,8 +82,9 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234
// Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString)
val existingUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2)
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
@@ -81,29 +101,30 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000)
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.position)
assertEquals(45.0, result.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
assertNotNull(result)
assertNotNull(result.position)
assertEquals(450000000, result.position.latitude_i)
assertEquals(900000000, result.position.longitude_i)
}
@Test
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
val nodeNum = 1234
val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
// Receive "zero" position with new satellite count
val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals(45.0, result!!.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
assertEquals(450000000, result!!.position.latitude_i)
assertEquals(900000000, result.position.longitude_i)
assertEquals(5, result.position.sats_in_view)
assertEquals(1001, result.lastHeard)
}
@@ -111,13 +132,13 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedPosition for local node ignores purely empty packets`() {
val myNum = 1111
val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
val result = nodeManager.nodeDBbyNodeNum[myNum]
// Should still be a default/unset node if it didn't exist, or shouldn't have position
assertTrue(result == null || result.position.latitude_i == null)
// Should still be null since the empty position for local node is ignored
assertNull(result)
}
@Test
@@ -125,11 +146,7 @@ class NodeManagerImplTest {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
val telemetry =
org.meshtastic.proto.Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
)
val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -140,10 +157,7 @@ class NodeManagerImplTest {
@Test
fun `handleReceivedTelemetry updates device metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
)
val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -157,10 +171,7 @@ class NodeManagerImplTest {
fun `handleReceivedTelemetry updates environment metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
environment_metrics =
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
)
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f))
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
@@ -180,5 +191,39 @@ class NodeManagerImplTest {
assertNull(nodeManager.myNodeNum)
}
*/
@Test
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
assertEquals(DataPacket.ID_BROADCAST, result)
}
@Test
fun `toNodeID returns default hex ID for unknown node`() {
val result = nodeManager.toNodeID(0x1234)
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
}
@Test
fun `toNodeID returns user ID for known node`() {
val nodeNum = 5678
val userId = "!customid"
nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) }
val result = nodeManager.toNodeID(nodeNum)
assertEquals(userId, result)
}
@Test
fun `removeByNodenum removes node from both maps`() {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) {
Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T"))
}
assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode"))
nodeManager.removeByNodenum(nodeNum)
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
}
}