mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)
This commit is contained in:
parent
e111b61e4e
commit
6af3ad6f0c
62 changed files with 3808 additions and 735 deletions
|
|
@ -31,7 +31,7 @@ object Exceptions {
|
|||
*/
|
||||
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
|
||||
// Log locally first
|
||||
Logger.e(exception) { "Exceptions.report: $tag $message" }
|
||||
Logger.e(exception) { "Exceptions.report: ${tag ?: "no-tag"} ${message ?: "no-message"}" }
|
||||
reporter?.invoke(exception, tag, message)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,17 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Suspend-compatible variant of [ignoreException]. */
|
||||
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
if (!silent) {
|
||||
Logger.w(ex) { "Ignoring exception" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
|
||||
* should not crash the process but are still unexpected.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* 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.common.util
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ExceptionsTest {
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Exceptions.reporter = null
|
||||
}
|
||||
|
||||
// ---------- Exceptions.report ----------
|
||||
|
||||
@Test
|
||||
fun `report invokes configured reporter with all arguments`() {
|
||||
var captured: Triple<Throwable, String?, String?>? = null
|
||||
Exceptions.reporter = { ex, tag, msg -> captured = Triple(ex, tag, msg) }
|
||||
|
||||
val error = RuntimeException("boom")
|
||||
Exceptions.report(error, tag = "MyTag", message = "context")
|
||||
|
||||
assertEquals(error, captured?.first)
|
||||
assertEquals("MyTag", captured?.second)
|
||||
assertEquals("context", captured?.third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `report works with null tag and message`() {
|
||||
var captured: Triple<Throwable, String?, String?>? = null
|
||||
Exceptions.reporter = { ex, tag, msg -> captured = Triple(ex, tag, msg) }
|
||||
|
||||
Exceptions.report(RuntimeException("x"))
|
||||
|
||||
assertNull(captured?.second)
|
||||
assertNull(captured?.third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `report does not crash when no reporter is configured`() {
|
||||
Exceptions.reporter = null
|
||||
// Should not throw
|
||||
Exceptions.report(RuntimeException("no reporter"))
|
||||
}
|
||||
|
||||
// ---------- ignoreException ----------
|
||||
|
||||
@Test
|
||||
fun `ignoreException swallows exceptions from inner block`() {
|
||||
var reached = false
|
||||
ignoreException { throw IllegalStateException("expected") }
|
||||
reached = true
|
||||
assertTrue(reached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignoreException does not swallow when inner succeeds`() {
|
||||
var executed = false
|
||||
ignoreException { executed = true }
|
||||
assertTrue(executed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignoreException silent mode suppresses logging`() {
|
||||
// Should not crash even in silent mode
|
||||
ignoreException(silent = true) { throw RuntimeException("silent") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignoreException non-silent mode logs but does not crash`() {
|
||||
ignoreException(silent = false) { throw RuntimeException("logged") }
|
||||
}
|
||||
|
||||
// ---------- ignoreExceptionSuspend ----------
|
||||
|
||||
@Test
|
||||
fun `ignoreExceptionSuspend swallows exceptions`() = runTest {
|
||||
var reached = false
|
||||
ignoreExceptionSuspend { throw IllegalArgumentException("async boom") }
|
||||
reached = true
|
||||
assertTrue(reached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignoreExceptionSuspend silent mode suppresses logging`() = runTest {
|
||||
ignoreExceptionSuspend(silent = true) { throw RuntimeException("silent async") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignoreExceptionSuspend executes block normally when no exception`() = runTest {
|
||||
var executed = false
|
||||
ignoreExceptionSuspend { executed = true }
|
||||
assertTrue(executed)
|
||||
}
|
||||
|
||||
// ---------- exceptionReporter ----------
|
||||
|
||||
@Test
|
||||
fun `exceptionReporter reports exceptions to configured reporter`() {
|
||||
var reportCalled = false
|
||||
Exceptions.reporter = { _, _, _ -> reportCalled = true }
|
||||
|
||||
exceptionReporter { throw RuntimeException("reported") }
|
||||
|
||||
assertTrue(reportCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exceptionReporter does not invoke reporter when block succeeds`() {
|
||||
var reportCalled = false
|
||||
Exceptions.reporter = { _, _, _ -> reportCalled = true }
|
||||
|
||||
exceptionReporter {
|
||||
// no exception
|
||||
}
|
||||
|
||||
assertFalse(reportCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exceptionReporter works without configured reporter`() {
|
||||
Exceptions.reporter = null
|
||||
// Should not crash
|
||||
exceptionReporter { throw RuntimeException("no reporter configured") }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/**
|
||||
* Implementation of [AdminPacketHandler] that processes admin messages, including session passkeys, device/module
|
||||
* configuration, and metadata.
|
||||
*/
|
||||
@Single
|
||||
class AdminPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val configHandler: Lazy<MeshConfigHandler>,
|
||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||
private val commandSender: CommandSender,
|
||||
) : AdminPacketHandler {
|
||||
|
||||
override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = AdminMessage.ADAPTER.decode(payload)
|
||||
Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" }
|
||||
// Guard against clearing a valid passkey: firmware always embeds the key in every
|
||||
// admin response, but a missing (default-empty) field must not reset the stored value.
|
||||
val incomingPasskey = u.session_passkey
|
||||
if (incomingPasskey.size > 0) {
|
||||
Logger.d { "Session passkey updated (${incomingPasskey.size} bytes)" }
|
||||
commandSender.setSessionPasskey(incomingPasskey)
|
||||
}
|
||||
|
||||
val fromNum = packet.from
|
||||
u.get_module_config_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configHandler.value.handleModuleConfig(it)
|
||||
} else {
|
||||
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (fromNum == myNodeNum) {
|
||||
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
|
||||
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
|
||||
}
|
||||
|
||||
u.get_device_metadata_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configFlowManager.value.handleLocalMetadata(it)
|
||||
} else {
|
||||
nodeManager.insertMetadata(fromNum, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a short summary of the non-null admin message fields for logging. */
|
||||
private fun AdminMessage.summarize(): String = buildList {
|
||||
get_config_response?.let { add("get_config_response") }
|
||||
get_module_config_response?.let { add("get_module_config_response") }
|
||||
get_channel_response?.let { add("get_channel_response") }
|
||||
get_device_metadata_response?.let { add("get_device_metadata_response") }
|
||||
if (session_passkey.size > 0) add("session_passkey")
|
||||
}
|
||||
.joinToString()
|
||||
.ifEmpty { "empty" }
|
||||
|
|
@ -19,14 +19,12 @@ package org.meshtastic.core.data.manager
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
|
|
@ -62,7 +60,7 @@ class CommandSenderImpl(
|
|||
private val tracerouteHandler: TracerouteHandler,
|
||||
private val neighborInfoHandler: NeighborInfoHandler,
|
||||
) : CommandSender {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
||||
private val sessionPasskey = atomic(ByteString.EMPTY)
|
||||
|
||||
|
|
@ -98,7 +96,7 @@ class CommandSenderImpl(
|
|||
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
|
||||
|
||||
private fun getAdminChannelIndex(toNum: Int): Int {
|
||||
val myNum = nodeManager.myNodeNum ?: return 0
|
||||
val myNum = nodeManager.myNodeNum.value ?: return 0
|
||||
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
|
||||
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
|
||||
|
||||
|
|
@ -169,8 +167,20 @@ class CommandSenderImpl(
|
|||
packetHandler.sendToRadio(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendAdminAwait(
|
||||
destNum: Int,
|
||||
requestId: Int,
|
||||
wantResponse: Boolean,
|
||||
initFn: () -> AdminMessage,
|
||||
): Boolean {
|
||||
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
|
||||
val packet =
|
||||
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
|
||||
return packetHandler.sendToRadioAndAwait(packet)
|
||||
}
|
||||
|
||||
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
|
||||
val myNum = nodeManager.myNodeNum ?: return
|
||||
val myNum = nodeManager.myNodeNum.value ?: return
|
||||
val idNum = destNum ?: myNum
|
||||
Logger.d { "Sending our position/time to=$idNum $pos" }
|
||||
|
||||
|
|
@ -230,11 +240,11 @@ class CommandSenderImpl(
|
|||
AdminMessage(remove_fixed_position = true)
|
||||
}
|
||||
}
|
||||
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos, nowMillis)
|
||||
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
|
||||
}
|
||||
|
||||
override fun requestUserInfo(destNum: Int) {
|
||||
val myNum = nodeManager.myNodeNum ?: return
|
||||
val myNum = nodeManager.myNodeNum.value ?: return
|
||||
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
|
|
@ -303,7 +313,7 @@ class CommandSenderImpl(
|
|||
|
||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
neighborInfoHandler.recordStartTime(requestId)
|
||||
val myNum = nodeManager.myNodeNum ?: 0
|
||||
val myNum = nodeManager.myNodeNum.value ?: 0
|
||||
if (destNum == myNum) {
|
||||
val neighborInfoToSend =
|
||||
neighborInfoHandler.lastNeighborInfo
|
||||
|
|
@ -392,7 +402,7 @@ class CommandSenderImpl(
|
|||
}
|
||||
|
||||
return MeshPacket(
|
||||
from = nodeManager.myNodeNum ?: 0,
|
||||
from = nodeManager.myNodeNum.value ?: 0,
|
||||
to = to,
|
||||
id = id,
|
||||
want_ack = wantAck,
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ class FromRadioPacketHandlerImpl(
|
|||
notificationManager.dispatch(
|
||||
Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert),
|
||||
)
|
||||
packetHandler.removeResponse(0, complete = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,14 +16,13 @@
|
|||
*/
|
||||
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 org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.ignoreExceptionSuspend
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
|
|
@ -66,7 +65,7 @@ class MeshActionHandlerImpl(
|
|||
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
) : MeshActionHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
|
|
@ -77,9 +76,10 @@ class MeshActionHandlerImpl(
|
|||
private const val EMOJI_INDICATOR = 1
|
||||
}
|
||||
|
||||
override fun onServiceAction(action: ServiceAction) {
|
||||
ignoreException {
|
||||
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
|
||||
override suspend fun onServiceAction(action: ServiceAction) {
|
||||
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
|
||||
ignoreExceptionSuspend {
|
||||
val myNodeNum = nodeManager.myNodeNum.value ?: return@ignoreExceptionSuspend
|
||||
when (action) {
|
||||
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
|
||||
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
|
||||
|
|
@ -87,7 +87,12 @@ class MeshActionHandlerImpl(
|
|||
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
|
||||
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
|
||||
is ServiceAction.SendContact -> {
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = action.contact) }
|
||||
val accepted =
|
||||
runCatching {
|
||||
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
|
||||
}
|
||||
.getOrDefault(false)
|
||||
action.result.complete(accepted)
|
||||
}
|
||||
is ServiceAction.GetDeviceMetadata -> {
|
||||
commandSender.sendAdmin(action.destNum, wantResponse = true) {
|
||||
|
|
@ -180,6 +185,7 @@ class MeshActionHandlerImpl(
|
|||
}
|
||||
|
||||
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
|
||||
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
|
||||
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
|
||||
nodeManager.handleReceivedUser(myNodeNum, newUser)
|
||||
|
|
@ -253,7 +259,7 @@ class MeshActionHandlerImpl(
|
|||
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
|
||||
// Optimistically persist module config locally so the UI reflects the
|
||||
// new values immediately instead of waiting for the next want_config handshake.
|
||||
if (destNum == nodeManager.myNodeNum) {
|
||||
if (destNum == nodeManager.myNodeNum.value) {
|
||||
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +335,7 @@ class MeshActionHandlerImpl(
|
|||
}
|
||||
|
||||
override fun handleRequestReboot(requestId: Int, destNum: Int) {
|
||||
Logger.i { "Reboot requested for node $destNum" }
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
|
||||
}
|
||||
|
||||
|
|
@ -340,6 +347,7 @@ class MeshActionHandlerImpl(
|
|||
}
|
||||
|
||||
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
|
||||
Logger.i { "Factory reset requested for node $destNum" }
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
|
||||
}
|
||||
|
||||
|
|
@ -356,6 +364,7 @@ class MeshActionHandlerImpl(
|
|||
override fun handleUpdateLastAddress(deviceAddr: String?) {
|
||||
val currentAddr = meshPrefs.deviceAddress.value
|
||||
if (deviceAddr != currentAddr) {
|
||||
Logger.i { "Device address changed, switching database and clearing node DB" }
|
||||
meshPrefs.setDeviceAddress(deviceAddr)
|
||||
scope.handledLaunch {
|
||||
nodeManager.clear()
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ package org.meshtastic.core.data.manager
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
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.ConnectionState
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
|
|
@ -58,47 +56,91 @@ class MeshConfigFlowManagerImpl(
|
|||
private val commandSender: CommandSender,
|
||||
private val packetHandler: PacketHandler,
|
||||
) : MeshConfigFlowManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val wantConfigDelay = 100L
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
private val newNodes = mutableListOf<NodeInfo>()
|
||||
override val newNodeCount: Int
|
||||
get() = newNodes.size
|
||||
/**
|
||||
* Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase,
|
||||
* eliminating the possibility of accessing stale or uninitialized fields.
|
||||
*
|
||||
* Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware
|
||||
* cannot trigger the wrong stage handler or drive the state machine backward.
|
||||
*/
|
||||
private sealed class HandshakeState {
|
||||
/** No handshake in progress. */
|
||||
data object Idle : HandshakeState()
|
||||
|
||||
private var rawMyNodeInfo: ProtoMyNodeInfo? = null
|
||||
private var lastMetadata: DeviceMetadata? = null
|
||||
private var newMyNodeInfo: SharedMyNodeInfo? = null
|
||||
private var myNodeInfo: SharedMyNodeInfo? = null
|
||||
/**
|
||||
* Stage 1: receiving device config, module config, channels, and metadata.
|
||||
*
|
||||
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
|
||||
* together by [buildMyNodeInfo] at Stage 1 completion.
|
||||
*/
|
||||
data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) :
|
||||
HandshakeState()
|
||||
|
||||
/**
|
||||
* Stage 2: receiving node-info packets from the firmware.
|
||||
*
|
||||
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
|
||||
* `config_complete_id` arrives.
|
||||
*/
|
||||
data class ReceivingNodeInfo(
|
||||
val myNodeInfo: SharedMyNodeInfo,
|
||||
val nodes: MutableList<NodeInfo> = mutableListOf(),
|
||||
) : HandshakeState()
|
||||
|
||||
/** Both stages finished. The app is fully connected. */
|
||||
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
|
||||
}
|
||||
|
||||
private var handshakeState: HandshakeState = HandshakeState.Idle
|
||||
|
||||
override val newNodeCount: Int
|
||||
get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0
|
||||
|
||||
override fun handleConfigComplete(configCompleteId: Int) {
|
||||
val state = handshakeState
|
||||
when (configCompleteId) {
|
||||
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
|
||||
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
|
||||
HandshakeConstants.CONFIG_NONCE -> {
|
||||
if (state !is HandshakeState.ReceivingConfig) {
|
||||
Logger.w { "Ignoring Stage 1 config_complete in state=$state" }
|
||||
return
|
||||
}
|
||||
handleConfigOnlyComplete(state)
|
||||
}
|
||||
HandshakeConstants.NODE_INFO_NONCE -> {
|
||||
if (state !is HandshakeState.ReceivingNodeInfo) {
|
||||
Logger.w { "Ignoring Stage 2 config_complete in state=$state" }
|
||||
return
|
||||
}
|
||||
handleNodeInfoComplete(state)
|
||||
}
|
||||
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConfigOnlyComplete() {
|
||||
private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) {
|
||||
Logger.i { "Config-only complete (Stage 1)" }
|
||||
if (newMyNodeInfo == null) {
|
||||
Logger.w {
|
||||
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
|
||||
|
||||
val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata)
|
||||
if (finalizedInfo == null) {
|
||||
Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" }
|
||||
handshakeState = HandshakeState.Idle
|
||||
scope.handledLaunch {
|
||||
delay(wantConfigDelay)
|
||||
connectionManager.value.startConfigOnly()
|
||||
}
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
return
|
||||
}
|
||||
|
||||
val finalizedInfo = newMyNodeInfo
|
||||
if (finalizedInfo == null) {
|
||||
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
|
||||
} else {
|
||||
myNodeInfo = finalizedInfo
|
||||
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||
connectionManager.value.onRadioConfigLoaded()
|
||||
}
|
||||
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
|
||||
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||
connectionManager.value.onRadioConfigLoaded()
|
||||
|
||||
scope.handledLaunch {
|
||||
delay(wantConfigDelay)
|
||||
|
|
@ -118,19 +160,34 @@ class MeshConfigFlowManagerImpl(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleNodeInfoComplete() {
|
||||
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
|
||||
Logger.i { "NodeInfo complete (Stage 2)" }
|
||||
val entities = newNodes.map { info ->
|
||||
nodeManager.installNodeInfo(info, withBroadcast = false)
|
||||
nodeManager.nodeDBbyNodeNum[info.num]!!
|
||||
}
|
||||
newNodes.clear()
|
||||
|
||||
val info = state.myNodeInfo
|
||||
|
||||
// Transition state immediately (synchronously) to prevent duplicate handling.
|
||||
// The async work below (DB writes, broadcasts) proceeds without the guard.
|
||||
handshakeState = HandshakeState.Complete(myNodeInfo = info)
|
||||
|
||||
// Snapshot and clear immediately so that a concurrent stall-guard retry (which
|
||||
// resends want_config_id and causes the firmware to restart the node_info burst)
|
||||
// starts accumulating into a fresh list rather than doubling this batch.
|
||||
val nodesToProcess = state.nodes.toList()
|
||||
state.nodes.clear()
|
||||
|
||||
val entities =
|
||||
nodesToProcess.mapNotNull { nodeInfo ->
|
||||
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
|
||||
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
|
||||
?: run {
|
||||
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
scope.handledLaunch {
|
||||
myNodeInfo?.let {
|
||||
nodeRepository.installConfig(it, entities)
|
||||
sendAnalytics(it)
|
||||
}
|
||||
nodeRepository.installConfig(info, entities)
|
||||
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
|
||||
nodeManager.setNodeDbReady(true)
|
||||
nodeManager.setAllowNodeDbWrites(true)
|
||||
serviceRepository.setConnectionState(ConnectionState.Connected)
|
||||
|
|
@ -139,16 +196,18 @@ class MeshConfigFlowManagerImpl(
|
|||
}
|
||||
}
|
||||
|
||||
private fun sendAnalytics(mi: SharedMyNodeInfo) {
|
||||
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
|
||||
}
|
||||
|
||||
override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
|
||||
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
|
||||
rawMyNodeInfo = myInfo
|
||||
nodeManager.myNodeNum = myInfo.my_node_num
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
|
||||
// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
|
||||
handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
|
||||
nodeManager.setMyNodeNum(myInfo.my_node_num)
|
||||
|
||||
// Clear persisted radio config so the new handshake starts from a clean slate.
|
||||
// DataStore serializes its own writes, so the clear will precede subsequent
|
||||
// setLocalConfig / updateChannelSettings calls dispatched by later packets in this
|
||||
// session (handleFromRadio processes packets sequentially, so later dispatches always
|
||||
// occur after this one returns).
|
||||
scope.handledLaunch {
|
||||
radioConfigRepository.clearChannelSet()
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
|
|
@ -160,12 +219,26 @@ class MeshConfigFlowManagerImpl(
|
|||
|
||||
override fun handleLocalMetadata(metadata: DeviceMetadata) {
|
||||
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
|
||||
lastMetadata = metadata
|
||||
regenMyNodeInfo(metadata)
|
||||
val state = handshakeState
|
||||
if (state is HandshakeState.ReceivingConfig) {
|
||||
state.metadata = metadata
|
||||
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
|
||||
// but the DB write does not need to wait until then.
|
||||
if (metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) }
|
||||
}
|
||||
} else {
|
||||
Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleNodeInfo(info: NodeInfo) {
|
||||
newNodes.add(info)
|
||||
val state = handshakeState
|
||||
if (state is HandshakeState.ReceivingNodeInfo) {
|
||||
state.nodes.add(info)
|
||||
} else {
|
||||
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleFileInfo(info: FileInfo) {
|
||||
|
|
@ -177,46 +250,38 @@ class MeshConfigFlowManagerImpl(
|
|||
connectionManager.value.startConfigOnly()
|
||||
}
|
||||
|
||||
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
|
||||
val myInfo = rawMyNodeInfo
|
||||
if (myInfo != null) {
|
||||
try {
|
||||
val mi =
|
||||
with(myInfo) {
|
||||
SharedMyNodeInfo(
|
||||
myNodeNum = my_node_num,
|
||||
hasGPS = false,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = device_id.utf8(),
|
||||
pioEnv = myInfo.pio_env.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
if (metadata != null && metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) }
|
||||
}
|
||||
newMyNodeInfo = mi
|
||||
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Failed to regenMyNodeInfo" }
|
||||
}
|
||||
} else {
|
||||
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
|
||||
/**
|
||||
* Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function — no side effects.
|
||||
* Returns null only if construction throws.
|
||||
*/
|
||||
private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try {
|
||||
with(raw) {
|
||||
SharedMyNodeInfo(
|
||||
myNodeNum = my_node_num,
|
||||
hasGPS = false,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = device_id.utf8(),
|
||||
pioEnv = pio_env.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Failed to build MyNodeInfo" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,14 @@
|
|||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
|
|
@ -42,7 +41,7 @@ class MeshConfigHandlerImpl(
|
|||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeManager: NodeManager,
|
||||
) : MeshConfigHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val _localConfig = MutableStateFlow(LocalConfig())
|
||||
override val localConfig = _localConfig.asStateFlow()
|
||||
|
|
@ -57,16 +56,18 @@ class MeshConfigHandlerImpl(
|
|||
}
|
||||
|
||||
override fun handleDeviceConfig(config: Config) {
|
||||
Logger.d { "Device config received: ${config.summarize()}" }
|
||||
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
|
||||
serviceRepository.setConnectionProgress("Device config received")
|
||||
}
|
||||
|
||||
override fun handleModuleConfig(config: ModuleConfig) {
|
||||
Logger.d { "Module config received: ${config.summarize()}" }
|
||||
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
|
||||
serviceRepository.setConnectionProgress("Module config received")
|
||||
|
||||
config.statusmessage?.let { sm ->
|
||||
nodeManager.myNodeNum?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
|
||||
nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +86,40 @@ class MeshConfigHandlerImpl(
|
|||
}
|
||||
|
||||
override fun handleDeviceUIConfig(config: DeviceUIConfig) {
|
||||
Logger.d { "DeviceUI config received" }
|
||||
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a short summary of which Config variant is set. */
|
||||
private fun Config.summarize(): String = when {
|
||||
device != null -> "device"
|
||||
position != null -> "position"
|
||||
power != null -> "power"
|
||||
network != null -> "network"
|
||||
display != null -> "display"
|
||||
lora != null -> "lora"
|
||||
bluetooth != null -> "bluetooth"
|
||||
security != null -> "security"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
/** Returns a short summary of which ModuleConfig variant is set. */
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun ModuleConfig.summarize(): String = when {
|
||||
mqtt != null -> "mqtt"
|
||||
serial != null -> "serial"
|
||||
external_notification != null -> "external_notification"
|
||||
store_forward != null -> "store_forward"
|
||||
range_test != null -> "range_test"
|
||||
telemetry != null -> "telemetry"
|
||||
canned_message != null -> "canned_message"
|
||||
audio != null -> "audio"
|
||||
remote_hardware != null -> "remote_hardware"
|
||||
neighbor_info != null -> "neighbor_info"
|
||||
ambient_lighting != null -> "ambient_lighting"
|
||||
detection_sensor != null -> "detection_sensor"
|
||||
paxcounter != null -> "paxcounter"
|
||||
statusmessage != null -> "statusmessage"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
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.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
|
|
@ -84,7 +82,7 @@ class MeshConnectionManagerImpl(
|
|||
private val workerManager: MeshWorkerManager,
|
||||
private val appWidgetUpdater: AppWidgetUpdater,
|
||||
) : MeshConnectionManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
private var sleepTimeout: Job? = null
|
||||
private var locationRequestsJob: Job? = null
|
||||
private var handshakeTimeout: Job? = null
|
||||
|
|
@ -127,22 +125,20 @@ class MeshConnectionManagerImpl(
|
|||
.launchIn(scope)
|
||||
}
|
||||
|
||||
private fun onRadioConnectionState(newState: ConnectionState) {
|
||||
scope.handledLaunch {
|
||||
val localConfig = radioConfigRepository.localConfigFlow.first()
|
||||
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
|
||||
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
|
||||
private suspend fun onRadioConnectionState(newState: ConnectionState) {
|
||||
val localConfig = radioConfigRepository.localConfigFlow.first()
|
||||
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
|
||||
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
|
||||
|
||||
val effectiveState =
|
||||
when (newState) {
|
||||
is ConnectionState.Connected -> ConnectionState.Connected
|
||||
is ConnectionState.DeviceSleep ->
|
||||
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
|
||||
is ConnectionState.Connecting -> ConnectionState.Connecting
|
||||
is ConnectionState.Disconnected -> ConnectionState.Disconnected
|
||||
}
|
||||
onConnectionChanged(effectiveState)
|
||||
}
|
||||
val effectiveState =
|
||||
when (newState) {
|
||||
is ConnectionState.Connected -> ConnectionState.Connected
|
||||
is ConnectionState.DeviceSleep ->
|
||||
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
|
||||
is ConnectionState.Connecting -> ConnectionState.Connecting
|
||||
is ConnectionState.Disconnected -> ConnectionState.Disconnected
|
||||
}
|
||||
onConnectionChanged(effectiveState)
|
||||
}
|
||||
|
||||
private fun onConnectionChanged(c: ConnectionState) {
|
||||
|
|
@ -195,23 +191,27 @@ class MeshConnectionManagerImpl(
|
|||
// the stall is on our side, the retry will be dropped and the reconnect below
|
||||
// will trigger instead — which is the right recovery in that case.
|
||||
Logger.w {
|
||||
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled."
|
||||
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled"
|
||||
}
|
||||
action()
|
||||
delay(HANDSHAKE_RETRY_TIMEOUT)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
|
||||
Logger.e { "Handshake still stalled after retry, forcing reconnect" }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeviceSleep() {
|
||||
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
|
||||
private fun tearDownConnection() {
|
||||
packetHandler.stopPacketQueue()
|
||||
locationManager.stop()
|
||||
mqttManager.stop()
|
||||
}
|
||||
|
||||
private fun handleDeviceSleep() {
|
||||
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
|
||||
tearDownConnection()
|
||||
|
||||
if (connectTimeMsec != 0L) {
|
||||
val now = nowMillis
|
||||
|
|
@ -230,7 +230,7 @@ class MeshConnectionManagerImpl(
|
|||
val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
|
||||
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
|
||||
delay(timeout.seconds)
|
||||
Logger.w { "Device timeout out, setting disconnected" }
|
||||
Logger.w { "Device timed out, setting disconnected" }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
} catch (_: CancellationException) {
|
||||
Logger.d { "device sleep timeout cancelled" }
|
||||
|
|
@ -242,9 +242,7 @@ class MeshConnectionManagerImpl(
|
|||
|
||||
private fun handleDisconnected() {
|
||||
serviceRepository.setConnectionState(ConnectionState.Disconnected)
|
||||
packetHandler.stopPacketQueue()
|
||||
locationManager.stop()
|
||||
mqttManager.stop()
|
||||
tearDownConnection()
|
||||
|
||||
analytics.track(
|
||||
EVENT_MESH_DISCONNECT,
|
||||
|
|
@ -285,7 +283,7 @@ class MeshConnectionManagerImpl(
|
|||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum ?: 0
|
||||
val myNodeNum = nodeManager.myNodeNum.value ?: 0
|
||||
|
||||
// Set device time now that the full node picture is ready. Sending this during Stage 1
|
||||
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
|
||||
|
|
|
|||
|
|
@ -20,14 +20,10 @@ import co.touchlab.kermit.Logger
|
|||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
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.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
|
|
@ -37,11 +33,8 @@ import org.meshtastic.core.model.Reaction
|
|||
import org.meshtastic.core.model.util.MeshDataMapper
|
||||
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.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
|
|
@ -56,38 +49,33 @@ 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.TelemetryPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
import org.meshtastic.core.resources.error_duty_cycle
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.waypoint_received
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.StatusMessage
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
|
||||
*
|
||||
* This class handles the complexity of:
|
||||
* 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
|
||||
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, SFPP).
|
||||
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, Telemetry, Admin, SFPP).
|
||||
* 3. Managing message history and persistence.
|
||||
* 4. Triggering notifications for various packet types (Text, Waypoints, Battery).
|
||||
* 5. Tracking received telemetry for node updates.
|
||||
* 4. Triggering notifications for various packet types (Text, Waypoints).
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
|
||||
@Single
|
||||
class MeshDataHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
|
|
@ -99,24 +87,20 @@ class MeshDataHandlerImpl(
|
|||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val dataMapper: MeshDataMapper,
|
||||
private val configHandler: Lazy<MeshConfigHandler>,
|
||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||
private val commandSender: CommandSender,
|
||||
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,
|
||||
private val telemetryHandler: TelemetryPacketHandler,
|
||||
private val adminPacketHandler: AdminPacketHandler,
|
||||
) : MeshDataHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val batteryMutex = Mutex()
|
||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
storeForwardHandler.start(scope)
|
||||
telemetryHandler.start(scope)
|
||||
}
|
||||
|
||||
private val rememberDataType =
|
||||
|
|
@ -157,7 +141,7 @@ class MeshDataHandlerImpl(
|
|||
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
|
||||
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
|
||||
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
|
||||
PortNum.TELEMETRY_APP -> handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
else ->
|
||||
shouldBroadcast =
|
||||
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
|
||||
|
|
@ -198,7 +182,7 @@ class MeshDataHandlerImpl(
|
|||
}
|
||||
|
||||
PortNum.ADMIN_APP -> {
|
||||
handleAdminMessage(packet, myNodeNum)
|
||||
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
|
||||
}
|
||||
|
||||
PortNum.NEIGHBORINFO_APP -> {
|
||||
|
|
@ -255,37 +239,6 @@ class MeshDataHandlerImpl(
|
|||
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
|
||||
}
|
||||
|
||||
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = AdminMessage.ADAPTER.decode(payload)
|
||||
// Guard against clearing a valid passkey: firmware always embeds the key in every
|
||||
// admin response, but a missing (default-empty) field must not reset the stored value.
|
||||
val incomingPasskey = u.session_passkey
|
||||
if (incomingPasskey.size > 0) commandSender.setSessionPasskey(incomingPasskey)
|
||||
|
||||
val fromNum = packet.from
|
||||
u.get_module_config_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configHandler.value.handleModuleConfig(it)
|
||||
} else {
|
||||
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (fromNum == myNodeNum) {
|
||||
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
|
||||
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
|
||||
}
|
||||
|
||||
u.get_device_metadata_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configFlowManager.value.handleLocalMetadata(it)
|
||||
} else {
|
||||
nodeManager.insertMetadata(fromNum, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val decoded = packet.decoded ?: return
|
||||
if (decoded.reply_id != 0 && decoded.emoji != 0) {
|
||||
|
|
@ -311,107 +264,6 @@ class MeshDataHandlerImpl(
|
|||
rememberDataPacket(dataPacket, myNodeNum)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val t =
|
||||
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
|
||||
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
|
||||
}
|
||||
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
|
||||
val fromNum = packet.from
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
if (!isRemote) {
|
||||
connectionManager.value.updateTelemetry(t)
|
||||
}
|
||||
|
||||
nodeManager.updateNode(fromNum) { node: Node ->
|
||||
val metrics = t.device_metrics
|
||||
val environment = t.environment_metrics
|
||||
val power = t.power_metrics
|
||||
|
||||
var nextNode = node
|
||||
when {
|
||||
metrics != null -> {
|
||||
nextNode = nextNode.copy(deviceMetrics = metrics)
|
||||
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
|
||||
if (
|
||||
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
|
||||
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
|
||||
) {
|
||||
scope.launch {
|
||||
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_title,
|
||||
nextNode.user.short_name,
|
||||
),
|
||||
message =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_message,
|
||||
nextNode.user.long_name,
|
||||
nextNode.deviceMetrics.battery_level ?: 0,
|
||||
),
|
||||
category = Notification.Category.Battery,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
batteryMutex.withLock {
|
||||
if (batteryPercentCooldowns.containsKey(fromNum)) {
|
||||
batteryPercentCooldowns.remove(fromNum)
|
||||
}
|
||||
}
|
||||
notificationManager.cancel(nextNode.num)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
|
||||
power != null -> nextNode = nextNode.copy(powerMetrics = power)
|
||||
}
|
||||
|
||||
val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
|
||||
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
|
||||
nextNode.copy(lastHeard = newLastHeard)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
var shouldDisplay = false
|
||||
var forceDisplay = false
|
||||
val metrics = t.device_metrics ?: return false
|
||||
val batteryLevel = metrics.battery_level ?: 0
|
||||
when {
|
||||
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
|
||||
shouldDisplay = true
|
||||
forceDisplay = true
|
||||
}
|
||||
|
||||
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
|
||||
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
|
||||
|
||||
isRemote -> shouldDisplay = true
|
||||
}
|
||||
if (shouldDisplay) {
|
||||
val now = nowSeconds
|
||||
batteryMutex.withLock {
|
||||
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
|
||||
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
|
||||
batteryPercentCooldowns[fromNum] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
|
|
@ -628,12 +480,13 @@ class MeshDataHandlerImpl(
|
|||
return@handledLaunch
|
||||
}
|
||||
|
||||
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum ?: 0)
|
||||
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0)
|
||||
|
||||
// Find the original packet to get the contactKey
|
||||
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
|
||||
// Skip notification if the original message was filtered
|
||||
val targetId = if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
|
||||
val targetId =
|
||||
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
|
||||
val contactKey = "${originalPacket.channel}$targetId"
|
||||
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
|
||||
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
|
||||
|
|
@ -642,7 +495,11 @@ class MeshDataHandlerImpl(
|
|||
if (!isSilent) {
|
||||
val channelName =
|
||||
if (originalPacket.to == DataPacket.ID_BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow.first().settings.getOrNull(originalPacket.channel)?.name
|
||||
radioConfigRepository.channelSetFlow
|
||||
.first()
|
||||
.settings
|
||||
.getOrNull(originalPacket.channel)
|
||||
?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -660,11 +517,5 @@ class MeshDataHandlerImpl(
|
|||
|
||||
companion object {
|
||||
private const val HOPS_AWAY_UNAVAILABLE = -1
|
||||
|
||||
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
|
||||
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
|
||||
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
|
||||
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
|
||||
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -27,7 +26,6 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
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.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
|
|
@ -55,7 +53,7 @@ class MeshMessageProcessorImpl(
|
|||
private val router: Lazy<MeshRouter>,
|
||||
private val fromRadioDispatcher: FromRadioPacketHandler,
|
||||
) : MeshMessageProcessor {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val mapsMutex = Mutex()
|
||||
private val logUuidByPacketId = mutableMapOf<Int, String>()
|
||||
|
|
@ -152,6 +150,7 @@ class MeshMessageProcessorImpl(
|
|||
earlyMutex.withLock {
|
||||
val queueSize = earlyReceivedPackets.size
|
||||
if (queueSize >= maxEarlyPacketBuffer) {
|
||||
Logger.w { "Early packet buffer full ($queueSize), dropping oldest packet" }
|
||||
earlyReceivedPackets.removeFirstOrNull()
|
||||
}
|
||||
earlyReceivedPackets.addLast(preparedPacket)
|
||||
|
|
@ -162,16 +161,17 @@ class MeshMessageProcessorImpl(
|
|||
|
||||
private fun flushEarlyReceivedPackets(reason: String) {
|
||||
scope.launch {
|
||||
val packets = earlyMutex.withLock {
|
||||
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
|
||||
val list = earlyReceivedPackets.toList()
|
||||
earlyReceivedPackets.clear()
|
||||
list
|
||||
}
|
||||
val packets =
|
||||
earlyMutex.withLock {
|
||||
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
|
||||
val list = earlyReceivedPackets.toList()
|
||||
earlyReceivedPackets.clear()
|
||||
list
|
||||
}
|
||||
if (packets.isEmpty()) return@launch
|
||||
|
||||
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
|
||||
val myNodeNum = nodeManager.myNodeNum
|
||||
val myNodeNum = nodeManager.myNodeNum.value
|
||||
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@ import co.touchlab.kermit.Logger
|
|||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
|
|
@ -39,7 +37,7 @@ class MqttManagerImpl(
|
|||
private val packetHandler: PacketHandler,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : MqttManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
private var mqttMessageFlow: Job? = null
|
||||
|
||||
override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
|
||||
|
|
|
|||
|
|
@ -21,10 +21,8 @@ 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.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
|
|
@ -39,7 +37,7 @@ class NeighborInfoHandlerImpl(
|
|||
private val serviceRepository: ServiceRepository,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
) : NeighborInfoHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
|
|
@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl(
|
|||
|
||||
// Store the last neighbor info from our connected radio
|
||||
val from = packet.from
|
||||
if (from == nodeManager.myNodeNum) {
|
||||
if (from == nodeManager.myNodeNum.value) {
|
||||
lastNeighborInfo = ni
|
||||
Logger.d { "Stored last neighbor info from connected radio" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,11 @@ import kotlinx.atomicfu.atomic
|
|||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.ByteString
|
||||
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.DeviceMetrics
|
||||
import org.meshtastic.core.model.EnvironmentMetrics
|
||||
|
|
@ -62,7 +60,7 @@ class NodeManagerImpl(
|
|||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val notificationManager: NotificationManager,
|
||||
) : NodeManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
|
||||
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
|
||||
|
|
@ -84,7 +82,11 @@ class NodeManagerImpl(
|
|||
allowNodeDbWrites.value = allowed
|
||||
}
|
||||
|
||||
override var myNodeNum: Int? = null
|
||||
override val myNodeNum = MutableStateFlow<Int?>(null)
|
||||
|
||||
override fun setMyNodeNum(num: Int?) {
|
||||
myNodeNum.value = num
|
||||
}
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
|
|
@ -101,7 +103,7 @@ class NodeManagerImpl(
|
|||
val byId = mutableMapOf<String, Node>()
|
||||
nodes.values.forEach { byId[it.user.id] = it }
|
||||
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
|
||||
myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
|
||||
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +112,7 @@ class NodeManagerImpl(
|
|||
_nodeDBbyID.value = persistentMapOf()
|
||||
isNodeDbReady.value = false
|
||||
allowNodeDbWrites.value = false
|
||||
myNodeNum = null
|
||||
myNodeNum.value = null
|
||||
}
|
||||
|
||||
override fun getMyNodeInfo(): MyNodeInfo? {
|
||||
|
|
@ -135,7 +137,7 @@ class NodeManagerImpl(
|
|||
}
|
||||
|
||||
override fun getMyId(): String {
|
||||
val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
|
||||
val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
|
||||
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
|
||||
}
|
||||
|
||||
|
|
@ -271,9 +273,8 @@ class NodeManagerImpl(
|
|||
if (shouldPreserveExistingUser(node.user, user)) {
|
||||
// keep existing names
|
||||
} else {
|
||||
var newUser = user.let {
|
||||
if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it
|
||||
}
|
||||
var newUser =
|
||||
user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
|
||||
if (info.via_mqtt) {
|
||||
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -29,7 +30,6 @@ import kotlinx.coroutines.withTimeout
|
|||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
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.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
|
|
@ -67,16 +67,21 @@ class PacketHandlerImpl(
|
|||
}
|
||||
|
||||
private var queueJob: Job? = null
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher)
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val queueMutex = Mutex()
|
||||
private val queuedPackets = mutableListOf<MeshPacket>()
|
||||
|
||||
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
|
||||
// and the queue processor's finally block to prevent restarting a stopped queue.
|
||||
private var queueStopped = false
|
||||
|
||||
private val responseMutex = Mutex()
|
||||
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
queueStopped = false // Safe: called before any concurrent operations on this scope.
|
||||
}
|
||||
|
||||
override fun sendToRadio(p: ToRadio) {
|
||||
|
|
@ -104,22 +109,52 @@ class PacketHandlerImpl(
|
|||
|
||||
override fun sendToRadio(packet: MeshPacket) {
|
||||
scope.launch {
|
||||
queueMutex.withLock { queuedPackets.add(packet) }
|
||||
startPacketQueue()
|
||||
queueMutex.withLock {
|
||||
queuedPackets.add(packet)
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
|
||||
// Pre-register the deferred so the queue processor and QueueStatus handler
|
||||
// can find it immediately — no polling required.
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
responseMutex.withLock { queueResponse[packet.id] = deferred }
|
||||
queueMutex.withLock {
|
||||
queuedPackets.add(packet)
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
return try {
|
||||
withTimeout(TIMEOUT) { deferred.await() }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" }
|
||||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e // Preserve structured concurrency cancellation propagation.
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" }
|
||||
false
|
||||
} finally {
|
||||
responseMutex.withLock { queueResponse.remove(packet.id) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopPacketQueue() {
|
||||
if (queueJob?.isActive == true) {
|
||||
// Run async so callers (non-suspend) don't block, but all mutations are
|
||||
// serialized under the same mutexes used by the queue processor and senders.
|
||||
scope.launch {
|
||||
Logger.i { "Stopping packet queueJob" }
|
||||
queueJob?.cancel()
|
||||
queueJob = null
|
||||
scope.launch {
|
||||
queueMutex.withLock { queuedPackets.clear() }
|
||||
responseMutex.withLock {
|
||||
queueResponse.values.lastOrNull { !it.isCompleted }?.complete(false)
|
||||
queueResponse.clear()
|
||||
}
|
||||
queueMutex.withLock {
|
||||
queueStopped = true
|
||||
queueJob?.cancel()
|
||||
queueJob = null
|
||||
queuedPackets.clear()
|
||||
}
|
||||
responseMutex.withLock {
|
||||
queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
|
||||
queueResponse.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -144,33 +179,47 @@ class PacketHandlerImpl(
|
|||
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
|
||||
}
|
||||
|
||||
private fun startPacketQueue() {
|
||||
/**
|
||||
* Starts the packet queue processor. Must be called while holding [queueMutex] to ensure the check-then-start is
|
||||
* atomic — preventing two concurrent callers from launching duplicate processors.
|
||||
*/
|
||||
private fun startPacketQueueLocked() {
|
||||
if (queueStopped) return
|
||||
if (queueJob?.isActive == true) return
|
||||
queueJob = scope.handledLaunch {
|
||||
try {
|
||||
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
|
||||
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
try {
|
||||
val response = sendPacket(packet)
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
|
||||
val success = withTimeout(TIMEOUT) { response.await() }
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
|
||||
} finally {
|
||||
responseMutex.withLock { queueResponse.remove(packet.id) }
|
||||
queueJob =
|
||||
scope.handledLaunch {
|
||||
try {
|
||||
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
|
||||
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
try {
|
||||
val response = sendPacket(packet)
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
|
||||
val success = withTimeout(TIMEOUT) { response.await() }
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e // Preserve structured concurrency cancellation propagation.
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
|
||||
}
|
||||
// Do NOT remove from queueResponse here. Removal is owned by:
|
||||
// - handleQueueStatus (normal completion path)
|
||||
// - sendToRadioAndAwait's finally block (for await-style callers)
|
||||
// - stopPacketQueue (bulk cleanup on disconnect)
|
||||
}
|
||||
} finally {
|
||||
// Hold queueMutex so that clearing queueJob and the restart decision are
|
||||
// atomic with respect to new senders calling startPacketQueueLocked().
|
||||
queueMutex.withLock {
|
||||
queueJob = null
|
||||
if (!queueStopped && queuedPackets.isNotEmpty()) {
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
queueJob = null
|
||||
if (queueMutex.withLock { queuedPackets.isNotEmpty() }) {
|
||||
startPacketQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
|
||||
|
|
@ -194,8 +243,8 @@ class PacketHandlerImpl(
|
|||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private suspend fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
responseMutex.withLock { queueResponse[packet.id] = deferred }
|
||||
// Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one.
|
||||
val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
|
||||
try {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
throw RadioNotConnectedException()
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ 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
|
||||
|
|
@ -48,7 +46,7 @@ class StoreForwardPacketHandlerImpl(
|
|||
private val historyManager: HistoryManager,
|
||||
private val dataHandler: Lazy<MeshDataHandler>,
|
||||
) : StoreForwardPacketHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
|
|
@ -116,7 +114,7 @@ class StoreForwardPacketHandlerImpl(
|
|||
|
||||
Logger.d {
|
||||
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
|
||||
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
|
||||
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status"
|
||||
}
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.updateSFPPStatus(
|
||||
|
|
@ -126,7 +124,7 @@ class StoreForwardPacketHandlerImpl(
|
|||
hash = hash,
|
||||
status = status,
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
myNodeNum = nodeManager.myNodeNum ?: 0,
|
||||
myNodeNum = nodeManager.myNodeNum.value ?: 0,
|
||||
)
|
||||
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
||||
}
|
||||
|
|
@ -145,10 +143,8 @@ class StoreForwardPacketHandlerImpl(
|
|||
}
|
||||
|
||||
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" }
|
||||
val lastRequest = s.history?.last_request ?: 0
|
||||
Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
||||
when {
|
||||
s.stats != null -> {
|
||||
val text = s.stats.toString()
|
||||
|
|
@ -159,7 +155,8 @@ class StoreForwardPacketHandlerImpl(
|
|||
)
|
||||
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
h != null -> {
|
||||
s.history != null -> {
|
||||
val h = s.history!!
|
||||
val text =
|
||||
"Total messages: ${h.history_messages}\n" +
|
||||
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* 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.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.TelemetryPacketHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Implementation of [TelemetryPacketHandler] that processes telemetry packets and manages battery-level notifications
|
||||
* with cooldown logic.
|
||||
*/
|
||||
@Single
|
||||
class TelemetryPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
private val notificationManager: NotificationManager,
|
||||
) : TelemetryPacketHandler {
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val batteryMutex = Mutex()
|
||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val t =
|
||||
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
|
||||
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
|
||||
}
|
||||
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
|
||||
val fromNum = packet.from
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
if (!isRemote) {
|
||||
connectionManager.value.updateTelemetry(t)
|
||||
}
|
||||
|
||||
nodeManager.updateNode(fromNum) { node: Node ->
|
||||
val metrics = t.device_metrics
|
||||
val environment = t.environment_metrics
|
||||
val power = t.power_metrics
|
||||
|
||||
var nextNode = node
|
||||
when {
|
||||
metrics != null -> {
|
||||
nextNode = nextNode.copy(deviceMetrics = metrics)
|
||||
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
|
||||
if (
|
||||
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
|
||||
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
|
||||
) {
|
||||
scope.launch {
|
||||
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_title,
|
||||
nextNode.user.short_name,
|
||||
),
|
||||
message =
|
||||
getStringSuspend(
|
||||
Res.string.low_battery_message,
|
||||
nextNode.user.long_name,
|
||||
nextNode.deviceMetrics.battery_level ?: 0,
|
||||
),
|
||||
category = Notification.Category.Battery,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
batteryMutex.withLock {
|
||||
if (batteryPercentCooldowns.containsKey(fromNum)) {
|
||||
batteryPercentCooldowns.remove(fromNum)
|
||||
}
|
||||
}
|
||||
notificationManager.cancel(nextNode.num)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
|
||||
power != null -> nextNode = nextNode.copy(powerMetrics = power)
|
||||
}
|
||||
|
||||
val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
|
||||
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
|
||||
nextNode.copy(lastHeard = newLastHeard)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
var shouldDisplay = false
|
||||
var forceDisplay = false
|
||||
val metrics = t.device_metrics ?: return false
|
||||
val batteryLevel = metrics.battery_level ?: 0
|
||||
when {
|
||||
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
|
||||
shouldDisplay = true
|
||||
forceDisplay = true
|
||||
}
|
||||
|
||||
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
|
||||
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
|
||||
|
||||
isRemote -> shouldDisplay = true
|
||||
}
|
||||
if (shouldDisplay) {
|
||||
val now = nowSeconds
|
||||
batteryMutex.withLock {
|
||||
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
|
||||
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
|
||||
batteryPercentCooldowns[fromNum] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
|
||||
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
|
||||
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
|
||||
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
|
||||
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
|
||||
}
|
||||
}
|
||||
|
|
@ -22,11 +22,9 @@ 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.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
|
|
@ -45,7 +43,7 @@ class TracerouteHandlerImpl(
|
|||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) : TracerouteHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
class AdminPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val configHandler = mock<MeshConfigHandler>(MockMode.autofill)
|
||||
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
|
||||
private val commandSender = mock<CommandSender>(MockMode.autofill)
|
||||
|
||||
private lateinit var handler: AdminPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
AdminPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
configHandler = lazy { configHandler },
|
||||
configFlowManager = lazy { configFlowManager },
|
||||
commandSender = commandSender,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket {
|
||||
val payload = AdminMessage.ADAPTER.encode(adminMessage).toByteString()
|
||||
return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload))
|
||||
}
|
||||
|
||||
// ---------- Session passkey ----------
|
||||
|
||||
@Test
|
||||
fun `session passkey is updated when present`() {
|
||||
val passkey = ByteString.of(1, 2, 3, 4)
|
||||
val adminMsg = AdminMessage(session_passkey = passkey)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { commandSender.setSessionPasskey(passkey) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty session passkey does not clear existing passkey`() {
|
||||
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// setSessionPasskey should NOT be called for empty passkey
|
||||
}
|
||||
|
||||
// ---------- get_config_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_config_response from own node delegates to configHandler`() {
|
||||
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
|
||||
val adminMsg = AdminMessage(get_config_response = config)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleDeviceConfig(config) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_config_response from remote node is ignored`() {
|
||||
val config = Config(device = Config.DeviceConfig())
|
||||
val adminMsg = AdminMessage(get_config_response = config)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// configHandler.handleDeviceConfig should NOT be called
|
||||
}
|
||||
|
||||
// ---------- get_module_config_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from own node delegates to configHandler`() {
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleModuleConfig(moduleConfig) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from remote node updates node status`() {
|
||||
val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low"))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val remoteNode = 99999
|
||||
val packet = makePacket(remoteNode, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from remote without status message does not crash`() {
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash, no updateNodeStatus call
|
||||
}
|
||||
|
||||
// ---------- get_channel_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_channel_response from own node delegates to configHandler`() {
|
||||
val channel = Channel(index = 0)
|
||||
val adminMsg = AdminMessage(get_channel_response = channel)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleChannel(channel) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_channel_response from remote node is ignored`() {
|
||||
val channel = Channel(index = 0)
|
||||
val adminMsg = AdminMessage(get_channel_response = channel)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// configHandler.handleChannel should NOT be called
|
||||
}
|
||||
|
||||
// ---------- get_device_metadata_response ----------
|
||||
|
||||
@Test
|
||||
fun `device metadata from own node delegates to configFlowManager`() {
|
||||
val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3)
|
||||
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configFlowManager.handleLocalMetadata(metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device metadata from remote node delegates to nodeManager`() {
|
||||
val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM)
|
||||
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
|
||||
val remoteNode = 99999
|
||||
val packet = makePacket(remoteNode, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.insertMetadata(remoteNode, metadata) }
|
||||
}
|
||||
|
||||
// ---------- Edge cases ----------
|
||||
|
||||
@Test
|
||||
fun `packet with null decoded payload is ignored`() {
|
||||
val packet = MeshPacket(from = myNodeNum, decoded = null)
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packet with empty payload bytes is ignored`() {
|
||||
val packet =
|
||||
MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY))
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash — decodes as default AdminMessage with no fields set
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined admin message with passkey and config response`() {
|
||||
val passkey = ByteString.of(5, 6, 7, 8)
|
||||
val config = Config(lora = Config.LoRaConfig())
|
||||
val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { commandSender.setSessionPasskey(passkey) }
|
||||
verify { configHandler.handleDeviceConfig(config) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
/*
|
||||
* 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.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.verify.VerifyMode.Companion.not
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MeshActionHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val commandSender = mock<CommandSender>(MockMode.autofill)
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
|
||||
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
|
||||
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
|
||||
|
||||
private lateinit var handler: MeshActionHandlerImpl
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
companion object {
|
||||
private const val MY_NODE_NUM = 12345
|
||||
private const val REMOTE_NODE_NUM = 67890
|
||||
}
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { nodeManager.myNodeNum } returns myNodeNumFlow
|
||||
every { nodeManager.getMyId() } returns "!12345678"
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
handler =
|
||||
MeshActionHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
commandSender = commandSender,
|
||||
packetRepository = lazy { packetRepository },
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
dataHandler = lazy { dataHandler },
|
||||
analytics = analytics,
|
||||
meshPrefs = meshPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
messageProcessor = lazy { messageProcessor },
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress("new_addr")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { meshPrefs.setDeviceAddress("new_addr") }
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { messageProcessor.clearEarlyPackets() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
|
||||
verify { notificationManager.cancelAll() }
|
||||
verify { nodeManager.loadCachedNodeDB() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
|
||||
|
||||
handler.handleUpdateLastAddress("same_addr")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { meshPrefs.setDeviceAddress(any()) }
|
||||
verify(not) { nodeManager.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { meshPrefs.setDeviceAddress(null) }
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase(null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
|
||||
|
||||
handler.handleUpdateLastAddress(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { meshPrefs.setDeviceAddress(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress("new")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase("new") }
|
||||
verify { notificationManager.cancelAll() }
|
||||
verify { nodeManager.loadCachedNodeDB() }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: null myNodeNum early-return ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
myNodeNumFlow.value = null
|
||||
|
||||
val node = createTestNode(REMOTE_NODE_NUM)
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Favorite ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Ignore ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Ignore(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Mute ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Mute(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: GetDeviceMetadata ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: SendContact ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
|
||||
|
||||
val action = ServiceAction.SendContact(SharedContact())
|
||||
handler.onServiceAction(action)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(action.result.isCompleted)
|
||||
assertTrue(action.result.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
|
||||
|
||||
val action = ServiceAction.SendContact(SharedContact())
|
||||
handler.onServiceAction(action)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(action.result.isCompleted)
|
||||
assertFalse(action.result.await())
|
||||
}
|
||||
|
||||
// ---- onServiceAction: ImportContact ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
|
||||
val contact =
|
||||
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
|
||||
handler.onServiceAction(ServiceAction.ImportContact(contact))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetOwner ----
|
||||
|
||||
@Test
|
||||
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
|
||||
handler.start(testScope)
|
||||
val meshUser =
|
||||
MeshUser(
|
||||
id = "!12345678",
|
||||
longName = "Test Long",
|
||||
shortName = "TL",
|
||||
hwModel = HardwareModel.UNSET,
|
||||
isLicensed = false,
|
||||
)
|
||||
|
||||
handler.handleSetOwner(meshUser, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSend ----
|
||||
|
||||
@Test
|
||||
fun handleSend_sendsDataAndBroadcastsStatus() {
|
||||
handler.start(testScope)
|
||||
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
|
||||
|
||||
handler.handleSend(packet, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.sendData(any()) }
|
||||
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
|
||||
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestPosition: 3 branches ----
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_sameNode_doesNothing() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
|
||||
|
||||
verify(not) { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
|
||||
handler.start(testScope)
|
||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||
|
||||
val validPosition = Position(37.7749, -122.4194, 10)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
|
||||
handler.start(testScope)
|
||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
val invalidPosition = Position(0.0, 0.0, 0)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
|
||||
|
||||
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
|
||||
verify { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
|
||||
handler.start(testScope)
|
||||
every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
|
||||
|
||||
val validPosition = Position(37.7749, -122.4194, 10)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||
|
||||
// Should send zero position regardless of valid input
|
||||
verify { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetConfig: optimistic persist ----
|
||||
|
||||
@Test
|
||||
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
|
||||
|
||||
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
|
||||
val payload = Config.ADAPTER.encode(config)
|
||||
|
||||
handler.handleSetConfig(payload, MY_NODE_NUM)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetModuleConfig: conditional persist ----
|
||||
|
||||
@Test
|
||||
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
myNodeNumFlow.value = MY_NODE_NUM
|
||||
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
|
||||
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
|
||||
|
||||
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
myNodeNumFlow.value = MY_NODE_NUM
|
||||
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
|
||||
|
||||
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetChannel: null payload guard ----
|
||||
|
||||
@Test
|
||||
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
|
||||
|
||||
val channel = Channel(index = 1)
|
||||
val payload = Channel.ADAPTER.encode(channel)
|
||||
|
||||
handler.handleSetChannel(payload, MY_NODE_NUM)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetChannel_nullPayload_doesNothing() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleSetChannel(null, MY_NODE_NUM)
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRemoveByNodenum ----
|
||||
|
||||
@Test
|
||||
fun handleRemoveByNodenum_removesAndSendsAdmin() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
|
||||
|
||||
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetRemoteOwner ----
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
|
||||
handler.start(testScope)
|
||||
|
||||
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
|
||||
val payload = User.ADAPTER.encode(user)
|
||||
|
||||
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleGetRemoteConfig: sessionkey vs regular ----
|
||||
|
||||
@Test
|
||||
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetRemoteChannel: null payload guard ----
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteChannel_nullPayload_doesNothing() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
|
||||
handler.start(testScope)
|
||||
|
||||
val channel = Channel(index = 2)
|
||||
val payload = Channel.ADAPTER.encode(channel)
|
||||
|
||||
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestRebootOta: null hash ----
|
||||
|
||||
@Test
|
||||
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestRebootOta_withHash_sendsAdmin() {
|
||||
handler.start(testScope)
|
||||
|
||||
val hash = byteArrayOf(0x01, 0x02, 0x03)
|
||||
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestNodedbReset ----
|
||||
|
||||
@Test
|
||||
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
|
||||
handler.start(testScope)
|
||||
|
||||
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- Helper ----
|
||||
|
||||
private fun createTestNode(
|
||||
num: Int,
|
||||
isFavorite: Boolean = false,
|
||||
isIgnored: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
): Node = Node(
|
||||
num = num,
|
||||
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
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.encodeUtf8
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
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.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshConfigFlowManagerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
|
||||
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||
private val commandSender = mock<CommandSender>(MockMode.autofill)
|
||||
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var manager: MeshConfigFlowManagerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
private val protoMyNodeInfo =
|
||||
ProtoMyNodeInfo(
|
||||
my_node_num = myNodeNum,
|
||||
min_app_version = 30000,
|
||||
device_id = "test-device".encodeUtf8(),
|
||||
pio_env = "",
|
||||
)
|
||||
|
||||
private val metadata =
|
||||
DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { commandSender.getCurrentPacketId() } returns 100
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
|
||||
|
||||
manager =
|
||||
MeshConfigFlowManagerImpl(
|
||||
nodeManager = nodeManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
nodeRepository = nodeRepository,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
analytics = analytics,
|
||||
commandSender = commandSender,
|
||||
packetHandler = packetHandler,
|
||||
)
|
||||
manager.start(testScope)
|
||||
}
|
||||
|
||||
// ---------- handleMyInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setMyNodeNum(myNodeNum) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo clears persisted radio config`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.clearChannelSet() }
|
||||
verifySuspend { radioConfigRepository.clearLocalConfig() }
|
||||
verifySuspend { radioConfigRepository.clearLocalModuleConfig() }
|
||||
verifySuspend { radioConfigRepository.clearDeviceUIConfig() }
|
||||
verifySuspend { radioConfigRepository.clearFileManifest() }
|
||||
}
|
||||
|
||||
// ---------- handleLocalMetadata ----------
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata skips empty metadata`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Default/empty DeviceMetadata should not trigger insertMetadata
|
||||
manager.handleLocalMetadata(DeviceMetadata())
|
||||
advanceUntilIdle()
|
||||
|
||||
// insertMetadata should only have been called zero times for default metadata
|
||||
// (we just verify no crash occurs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest {
|
||||
// State is Idle — handleLocalMetadata should be a no-op
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
// No crash, no insertMetadata call
|
||||
}
|
||||
|
||||
// ---------- handleConfigComplete Stage 1 ----------
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
verify { connectionManager.startNodeInfoOnly() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No metadata provided
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest {
|
||||
// State is Idle — should be a no-op
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
// No crash, no onRadioConfigLoaded
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
// ---------- handleNodeInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest {
|
||||
// Transition to Stage 2
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now in ReceivingNodeInfo
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
manager.handleNodeInfo(NodeInfo(num = 200))
|
||||
|
||||
assertEquals(2, manager.newNodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest {
|
||||
// State is Idle
|
||||
manager.handleNodeInfo(NodeInfo(num = 999))
|
||||
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- handleConfigComplete Stage 2 ----------
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest {
|
||||
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
|
||||
|
||||
// Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { nodeManager.setAllowNodeDbWrites(true) }
|
||||
verify { serviceBroadcasts.broadcastConnection() }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest {
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No handleNodeInfo calls — empty node list
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
}
|
||||
|
||||
// ---------- Unknown config_complete_id ----------
|
||||
|
||||
@Test
|
||||
fun `Unknown config_complete_id is ignored`() = testScope.runTest {
|
||||
manager.handleConfigComplete(99999)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- newNodeCount ----------
|
||||
|
||||
@Test
|
||||
fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() {
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- handleFileInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest {
|
||||
val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024)
|
||||
manager.handleFileInfo(fileInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.addFileInfo(fileInfo) }
|
||||
}
|
||||
|
||||
// ---------- triggerWantConfig ----------
|
||||
|
||||
@Test
|
||||
fun `triggerWantConfig delegates to connectionManager startConfigOnly`() {
|
||||
manager.triggerWantConfig()
|
||||
verify { connectionManager.startConfigOnly() }
|
||||
}
|
||||
|
||||
// ---------- Full handshake flow ----------
|
||||
|
||||
@Test
|
||||
fun `Full handshake from Idle to Complete`() = testScope.runTest {
|
||||
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
|
||||
|
||||
// Stage 0: Idle -> handleMyInfo
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
verify { nodeManager.setMyNodeNum(myNodeNum) }
|
||||
|
||||
// Receive metadata during Stage 1
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stage 1 complete
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
|
||||
// Receive NodeInfo during Stage 2
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
assertEquals(1, manager.newNodeCount)
|
||||
|
||||
// Stage 2 complete
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
|
||||
// After complete, newNodeCount should be 0 (state is Complete)
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- Interrupted handshake ----------
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo resets stale handshake state`() = testScope.runTest {
|
||||
// Start first handshake
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Before Stage 1 completes, a new handleMyInfo arrives (device rebooted)
|
||||
val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999)
|
||||
manager.handleMyInfo(newMyInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setMyNodeNum(99999) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
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.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceUIConfig
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshConfigHandlerImplTest {
|
||||
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
|
||||
private val localConfigFlow = MutableStateFlow(LocalConfig())
|
||||
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var handler: MeshConfigHandlerImpl
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
|
||||
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
|
||||
|
||||
handler =
|
||||
MeshConfigHandlerImpl(
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
nodeManager = nodeManager,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- start and flow wiring ----------
|
||||
|
||||
@Test
|
||||
fun `start wires localConfig flow from repository`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
|
||||
localConfigFlow.value = config
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(config, handler.localConfig.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
moduleConfigFlow.value = config
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(config, handler.moduleConfig.value)
|
||||
}
|
||||
|
||||
// ---------- handleDeviceConfig ----------
|
||||
|
||||
@Test
|
||||
fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
|
||||
handler.handleDeviceConfig(config)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.setLocalConfig(config) }
|
||||
verify { serviceRepository.setConnectionProgress("Device config received") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val configs =
|
||||
listOf(
|
||||
Config(position = Config.PositionConfig()),
|
||||
Config(power = Config.PowerConfig()),
|
||||
Config(network = Config.NetworkConfig()),
|
||||
Config(display = Config.DisplayConfig()),
|
||||
Config(lora = Config.LoRaConfig()),
|
||||
Config(bluetooth = Config.BluetoothConfig()),
|
||||
Config(security = Config.SecurityConfig()),
|
||||
)
|
||||
|
||||
for (config in configs) {
|
||||
handler.handleDeviceConfig(config)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
// All should have been persisted (7 configs)
|
||||
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
|
||||
}
|
||||
|
||||
// ---------- handleModuleConfig ----------
|
||||
|
||||
@Test
|
||||
fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
handler.handleModuleConfig(config)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.setLocalModuleConfig(config) }
|
||||
verify { serviceRepository.setConnectionProgress("Module config received") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val myNum = 123
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNum)
|
||||
|
||||
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
|
||||
handler.handleModuleConfig(config)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.updateNodeStatus(myNum, "Active") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(null)
|
||||
|
||||
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
|
||||
handler.handleModuleConfig(config)
|
||||
advanceUntilIdle()
|
||||
// No crash — updateNodeStatus should not be called
|
||||
}
|
||||
|
||||
// ---------- handleChannel ----------
|
||||
|
||||
@Test
|
||||
fun `handleChannel persists channel settings`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val channel = Channel(index = 0)
|
||||
handler.handleChannel(channel)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.updateChannelSettings(channel) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
every { nodeManager.getMyNodeInfo() } returns
|
||||
MyNodeInfo(
|
||||
myNodeNum = 123,
|
||||
hasGPS = false,
|
||||
model = null,
|
||||
firmwareVersion = null,
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = 0L,
|
||||
messageTimeoutMsec = 0,
|
||||
minAppVersion = 0,
|
||||
maxChannels = 8,
|
||||
hasWifi = false,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = null,
|
||||
)
|
||||
|
||||
val channel = Channel(index = 2)
|
||||
handler.handleChannel(channel)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { serviceRepository.setConnectionProgress("Channels (3 / 8)") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
every { nodeManager.getMyNodeInfo() } returns null
|
||||
|
||||
val channel = Channel(index = 0)
|
||||
handler.handleChannel(channel)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { serviceRepository.setConnectionProgress("Channels (1)") }
|
||||
}
|
||||
|
||||
// ---------- handleDeviceUIConfig ----------
|
||||
|
||||
@Test
|
||||
fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) {
|
||||
handler.start(backgroundScope)
|
||||
val config = DeviceUIConfig()
|
||||
handler.handleDeviceUIConfig(config)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.setDeviceUIConfig(config) }
|
||||
}
|
||||
}
|
||||
|
|
@ -255,7 +255,7 @@ class MeshConnectionManagerImplTest {
|
|||
)
|
||||
moduleConfigFlow.value = moduleConfig
|
||||
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
|
||||
every { nodeManager.myNodeNum } returns 123
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
|
||||
every { mqttManager.start(any(), any(), any()) } returns Unit
|
||||
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
|
||||
every { nodeManager.getMyNodeInfo() } returns null
|
||||
|
|
|
|||
|
|
@ -35,10 +35,7 @@ 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.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
|
|
@ -51,6 +48,7 @@ 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.TelemetryPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Data
|
||||
|
|
@ -79,15 +77,13 @@ class MeshDataHandlerTest {
|
|||
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
|
||||
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
|
||||
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
|
||||
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||
private val commandSender: CommandSender = 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 telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
|
||||
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
|
@ -105,15 +101,13 @@ class MeshDataHandlerTest {
|
|||
serviceNotifications = serviceNotifications,
|
||||
analytics = analytics,
|
||||
dataMapper = dataMapper,
|
||||
configHandler = lazy { configHandler },
|
||||
configFlowManager = lazy { configFlowManager },
|
||||
commandSender = commandSender,
|
||||
connectionManager = lazy { connectionManager },
|
||||
tracerouteHandler = tracerouteHandler,
|
||||
neighborInfoHandler = neighborInfoHandler,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
messageFilter = messageFilter,
|
||||
storeForwardHandler = storeForwardHandler,
|
||||
telemetryHandler = telemetryHandler,
|
||||
adminPacketHandler = adminPacketHandler,
|
||||
)
|
||||
handler.start(testScope)
|
||||
|
||||
|
|
@ -428,7 +422,7 @@ class MeshDataHandlerTest {
|
|||
// --- Telemetry handling ---
|
||||
|
||||
@Test
|
||||
fun `telemetry packet updates node via nodeManager`() {
|
||||
fun `telemetry packet delegates to telemetryHandler`() {
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 2000,
|
||||
|
|
@ -451,11 +445,11 @@ class MeshDataHandlerTest {
|
|||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { nodeManager.updateNode(456, any(), any(), any()) }
|
||||
verify { telemetryHandler.handleTelemetry(packet, any(), 123) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `telemetry from local node also updates connectionManager`() {
|
||||
fun `telemetry from local node delegates to telemetryHandler`() {
|
||||
val myNodeNum = 123
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
|
|
@ -479,7 +473,7 @@ class MeshDataHandlerTest {
|
|||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { connectionManager.updateTelemetry(any()) }
|
||||
verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// --- Text message handling ---
|
||||
|
|
@ -490,10 +484,8 @@ class MeshDataHandlerTest {
|
|||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded = Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "hello".encodeToByteArray().toByteString(),
|
||||
),
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
|
|
@ -510,7 +502,8 @@ class MeshDataHandlerTest {
|
|||
// 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")),
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
|
@ -525,10 +518,8 @@ class MeshDataHandlerTest {
|
|||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded = Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "hello".encodeToByteArray().toByteString(),
|
||||
),
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
|
|
@ -583,7 +574,7 @@ class MeshDataHandlerTest {
|
|||
123 to Node(num = 123, user = User(id = "!local")),
|
||||
)
|
||||
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
|
||||
every { nodeManager.myNodeNum } returns 123
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
|
||||
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
|
@ -600,7 +591,8 @@ class MeshDataHandlerTest {
|
|||
MeshPacket(
|
||||
id = 55,
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
|
||||
decoded =
|
||||
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
|
|
@ -616,7 +608,8 @@ class MeshDataHandlerTest {
|
|||
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")),
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
|
@ -629,7 +622,7 @@ class MeshDataHandlerTest {
|
|||
// --- Admin message handling ---
|
||||
|
||||
@Test
|
||||
fun `admin message sets session passkey`() {
|
||||
fun `admin message delegates to adminPacketHandler`() {
|
||||
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()))
|
||||
|
|
@ -644,7 +637,7 @@ class MeshDataHandlerTest {
|
|||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { commandSender.setSessionPasskey(any()) }
|
||||
verify { adminPacketHandler.handleAdminMessage(packet, 123) }
|
||||
}
|
||||
|
||||
// --- Message filtering ---
|
||||
|
|
@ -688,10 +681,8 @@ class MeshDataHandlerTest {
|
|||
MeshPacket(
|
||||
id = 88,
|
||||
from = 456,
|
||||
decoded = Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "hello".encodeToByteArray().toByteString(),
|
||||
),
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
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.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.repository.FromRadioPacketHandler
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.MeshRouter
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.LogRecord
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshMessageProcessorImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
|
||||
private val meshLogRepository = mock<MeshLogRepository>(MockMode.autofill)
|
||||
private val router = mock<MeshRouter>(MockMode.autofill)
|
||||
private val fromRadioDispatcher = mock<FromRadioPacketHandler>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var processor: MeshMessageProcessorImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
private val isNodeDbReady = MutableStateFlow(false)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { nodeManager.isNodeDbReady } returns isNodeDbReady
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
|
||||
every { router.dataHandler } returns dataHandler
|
||||
|
||||
processor =
|
||||
MeshMessageProcessorImpl(
|
||||
nodeManager = nodeManager,
|
||||
serviceRepository = serviceRepository,
|
||||
meshLogRepository = lazy { meshLogRepository },
|
||||
router = lazy { router },
|
||||
fromRadioDispatcher = fromRadioDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- handleFromRadio: non-packet variants ----------
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
val logRecord = LogRecord(message = "test log")
|
||||
val fromRadio = FromRadio(log_record = logRecord)
|
||||
val bytes = FromRadio.ADAPTER.encode(fromRadio)
|
||||
|
||||
processor.handleFromRadio(bytes, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { fromRadioDispatcher.handleFromRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
// Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails,
|
||||
// fallback decode as LogRecord succeeds
|
||||
val logRecord = LogRecord(message = "fallback log")
|
||||
val bytes = LogRecord.ADAPTER.encode(logRecord)
|
||||
|
||||
processor.handleFromRadio(bytes, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should have been dispatched as a FromRadio with log_record set
|
||||
verify { fromRadioDispatcher.handleFromRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
// Invalid protobuf bytes — both parses should fail
|
||||
val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())
|
||||
|
||||
processor.handleFromRadio(garbage, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- handleReceivedMeshPacket: early buffering ----------
|
||||
|
||||
@Test
|
||||
fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = false
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 1,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1000,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Packet should be buffered, not processed
|
||||
// (no emitMeshPacket call since DB is not ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = false
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 1,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1000,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now make DB ready
|
||||
isNodeDbReady.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Buffered packet should have been flushed and processed
|
||||
verifySuspend { serviceRepository.emitMeshPacket(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = false
|
||||
|
||||
// The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test,
|
||||
// but we test the boundary behavior conceptually. Instead, test that multiple
|
||||
// packets are accumulated properly.
|
||||
repeat(5) { i ->
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = i,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1000 + i,
|
||||
)
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
// Flush
|
||||
isNodeDbReady.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// All 5 packets should have been processed
|
||||
verifySuspend { serviceRepository.emitMeshPacket(any()) }
|
||||
}
|
||||
|
||||
// ---------- handleReceivedMeshPacket: rx_time normalization ----------
|
||||
|
||||
@Test
|
||||
fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 1,
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 0, // should be replaced with current time
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { serviceRepository.emitMeshPacket(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 2,
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1700000000,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { serviceRepository.emitMeshPacket(any()) }
|
||||
}
|
||||
|
||||
// ---------- handleReceivedMeshPacket: node updates ----------
|
||||
|
||||
@Test
|
||||
fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 10,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1700000000,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should have called updateNode for myNodeNum (lastHeard update)
|
||||
verify { nodeManager.updateNode(myNodeNum, withBroadcast = true, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val senderNode = 999
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 10,
|
||||
from = senderNode,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1700000000,
|
||||
channel = 1,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should have called updateNode for the sender
|
||||
verify { nodeManager.updateNode(senderNode, withBroadcast = false, any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- handleReceivedMeshPacket: null decoded ----------
|
||||
|
||||
@Test
|
||||
fun `packet with null decoded is skipped`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val packet = MeshPacket(id = 1, from = 999, decoded = null)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash, no emitMeshPacket call (decoded is null so processReceivedMeshPacket returns early)
|
||||
}
|
||||
|
||||
// ---------- handleReceivedMeshPacket: null myNodeNum ----------
|
||||
|
||||
@Test
|
||||
fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = true
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 10,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1700000000,
|
||||
)
|
||||
|
||||
processor.handleReceivedMeshPacket(packet, null)
|
||||
advanceUntilIdle()
|
||||
|
||||
// emitMeshPacket should still be called, but node updates should be skipped
|
||||
verifySuspend { serviceRepository.emitMeshPacket(any()) }
|
||||
}
|
||||
|
||||
// ---------- clearEarlyPackets ----------
|
||||
|
||||
@Test
|
||||
fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
isNodeDbReady.value = false
|
||||
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 1,
|
||||
from = 999,
|
||||
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY),
|
||||
rx_time = 1000,
|
||||
)
|
||||
processor.handleReceivedMeshPacket(packet, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
processor.clearEarlyPackets()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now make DB ready — the buffer should be empty, nothing to flush
|
||||
isNodeDbReady.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// emitMeshPacket should NOT have been called (buffer was cleared)
|
||||
}
|
||||
|
||||
// ---------- logVariant ----------
|
||||
|
||||
@Test
|
||||
fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) {
|
||||
processor.start(backgroundScope)
|
||||
val logRecord = LogRecord(message = "device log")
|
||||
val fromRadio = FromRadio(log_record = logRecord)
|
||||
val bytes = FromRadio.ADAPTER.encode(fromRadio)
|
||||
|
||||
processor.handleFromRadio(bytes, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { meshLogRepository.insert(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ class NodeManagerImplTest {
|
|||
|
||||
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
|
||||
assertTrue(nodeManager.nodeDBbyID.isEmpty())
|
||||
assertNull(nodeManager.myNodeNum)
|
||||
assertNull(nodeManager.myNodeNum.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
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
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
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.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.StoreAndForward
|
||||
import org.meshtastic.proto.StoreForwardPlusPlus
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StoreForwardPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val historyManager = mock<HistoryManager>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var handler: StoreForwardPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
|
||||
|
||||
handler =
|
||||
StoreForwardPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
packetRepository = lazy { packetRepository },
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
historyManager = historyManager,
|
||||
dataHandler = lazy { dataHandler },
|
||||
)
|
||||
handler.start(testScope)
|
||||
}
|
||||
|
||||
private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket {
|
||||
val payload = StoreAndForward.ADAPTER.encode(sf).toByteString()
|
||||
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
|
||||
}
|
||||
|
||||
private fun makeSfppPacket(from: Int, sfpp: StoreForwardPlusPlus): MeshPacket {
|
||||
val payload = StoreForwardPlusPlus.ADAPTER.encode(sfpp).toByteString()
|
||||
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
|
||||
}
|
||||
|
||||
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
|
||||
id = 1,
|
||||
time = 1700000000000L,
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
from = DataPacket.nodeNumToDefaultId(from),
|
||||
bytes = null,
|
||||
dataType = PortNum.STORE_FORWARD_APP.value,
|
||||
)
|
||||
|
||||
// ---------- Legacy S&F: stats ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward stats creates text data packet`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
stats = StoreAndForward.Statistics(messages_total = 100, messages_saved = 50, messages_max = 200),
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: history ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward history creates text packet and updates last request`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
history =
|
||||
StoreAndForward.History(history_messages = 42, window = 3600000, last_request = 1700000000),
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
verify { historyManager.updateStoreForwardLastRequest("router_history", 1700000000, "Unknown") }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: heartbeat ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward heartbeat does not crash`() = testScope.runTest {
|
||||
val sf = StoreAndForward(heartbeat = StoreAndForward.Heartbeat(period = 900, secondary = 1))
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash, just logs
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: text ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward text with broadcast rr sets to broadcast`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
text = "Hello from router".encodeToByteArray().toByteString(),
|
||||
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST,
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward text without broadcast rr preserves destination`() = testScope.runTest {
|
||||
val sf =
|
||||
StoreAndForward(
|
||||
text = "Direct message".encodeToByteArray().toByteString(),
|
||||
rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT,
|
||||
)
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { dataHandler.rememberDataPacket(any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: null payload ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward with null payload returns early`() = testScope.runTest {
|
||||
val packet = MeshPacket(from = 999, decoded = null)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- Legacy S&F: empty message ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreAndForward with no fields set does not crash`() = testScope.runTest {
|
||||
val sf = StoreAndForward()
|
||||
val packet = makeSfPacket(999, sf)
|
||||
val dataPacket = makeDataPacket(999)
|
||||
|
||||
handler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash — falls through to else branch
|
||||
}
|
||||
|
||||
// ---------- SF++: LINK_PROVIDE ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus LINK_PROVIDE with message_hash updates status`() = testScope.runTest {
|
||||
val sfpp =
|
||||
StoreForwardPlusPlus(
|
||||
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
||||
encapsulated_id = 42,
|
||||
encapsulated_from = 1000,
|
||||
encapsulated_to = 2000,
|
||||
message_hash = ByteString.of(0x01, 0x02, 0x03, 0x04),
|
||||
commit_hash = ByteString.EMPTY,
|
||||
)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
|
||||
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
|
||||
}
|
||||
|
||||
// ---------- SF++: CANON_ANNOUNCE ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus CANON_ANNOUNCE updates status by hash`() = testScope.runTest {
|
||||
val sfpp =
|
||||
StoreForwardPlusPlus(
|
||||
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE,
|
||||
message_hash = ByteString.of(0xAA.toByte(), 0xBB.toByte()),
|
||||
encapsulated_rxtime = 1700000000,
|
||||
)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatusByHash(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- SF++: CHAIN_QUERY ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus CHAIN_QUERY logs info without crash`() = testScope.runTest {
|
||||
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
// No crash, just logs
|
||||
}
|
||||
|
||||
// ---------- SF++: LINK_REQUEST ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus LINK_REQUEST logs info without crash`() = testScope.runTest {
|
||||
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
// No crash, just logs
|
||||
}
|
||||
|
||||
// ---------- SF++: invalid payload ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus with null payload returns early`() = testScope.runTest {
|
||||
val packet = MeshPacket(from = 999, decoded = null)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- SF++: fragment types ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus LINK_PROVIDE_FIRSTHALF handled as link provide`() = testScope.runTest {
|
||||
val sfpp =
|
||||
StoreForwardPlusPlus(
|
||||
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
|
||||
encapsulated_id = 55,
|
||||
encapsulated_from = 1000,
|
||||
encapsulated_to = 2000,
|
||||
message_hash = ByteString.of(0x01, 0x02),
|
||||
commit_hash = ByteString.EMPTY,
|
||||
)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus LINK_PROVIDE_SECONDHALF handled as link provide`() = testScope.runTest {
|
||||
val sfpp =
|
||||
StoreForwardPlusPlus(
|
||||
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
|
||||
encapsulated_id = 56,
|
||||
encapsulated_from = 1000,
|
||||
encapsulated_to = 2000,
|
||||
message_hash = ByteString.of(0x03, 0x04),
|
||||
commit_hash = ByteString.EMPTY,
|
||||
)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- SF++: commit_hash present changes status ----------
|
||||
|
||||
@Test
|
||||
fun `handleStoreForwardPlusPlus LINK_PROVIDE with commit_hash sets SFPP_CONFIRMED`() = testScope.runTest {
|
||||
val sfpp =
|
||||
StoreForwardPlusPlus(
|
||||
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
||||
encapsulated_id = 77,
|
||||
encapsulated_from = 1000,
|
||||
encapsulated_to = 2000,
|
||||
message_hash = ByteString.of(0x01, 0x02),
|
||||
commit_hash = ByteString.of(0xAA.toByte()), // non-empty
|
||||
)
|
||||
val packet = makeSfppPacket(999, sfpp)
|
||||
|
||||
handler.handleStoreForwardPlusPlus(packet)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
* 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.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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.DataPacket
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.PowerMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TelemetryPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var handler: TelemetryPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
private val remoteNodeNum = 99999
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
TelemetryPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
notificationManager = notificationManager,
|
||||
)
|
||||
handler.start(testScope)
|
||||
}
|
||||
|
||||
private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket {
|
||||
val payload = Telemetry.ADAPTER.encode(telemetry).toByteString()
|
||||
return MeshPacket(
|
||||
from = from,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = payload),
|
||||
rx_time = 1700000000,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
|
||||
id = 1,
|
||||
time = 1700000000000L,
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
from = DataPacket.nodeNumToDefaultId(from),
|
||||
bytes = null,
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
)
|
||||
|
||||
// ---------- Device metrics from local node ----------
|
||||
|
||||
@Test
|
||||
fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.updateTelemetry(any()) }
|
||||
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Device metrics from remote node ----------
|
||||
|
||||
@Test
|
||||
fun `remote device metrics updates node but not connectionManager`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f))
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Environment metrics ----------
|
||||
|
||||
@Test
|
||||
fun `environment metrics updates node with environment data`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 1700000000,
|
||||
environment_metrics = EnvironmentMetrics(temperature = 25.5f, relative_humidity = 60.0f),
|
||||
)
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Power metrics ----------
|
||||
|
||||
@Test
|
||||
fun `power metrics updates node with power data`() = testScope.runTest {
|
||||
val telemetry = Telemetry(time = 1700000000, power_metrics = PowerMetrics(ch1_voltage = 3.3f))
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(remoteNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Telemetry time handling ----------
|
||||
|
||||
@Test
|
||||
fun `telemetry with time 0 gets time from dataPacket`() = testScope.runTest {
|
||||
val telemetry = Telemetry(time = 0, device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.8f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Null payload ----------
|
||||
|
||||
@Test
|
||||
fun `handleTelemetry with null decoded payload returns early`() = testScope.runTest {
|
||||
val packet = MeshPacket(from = myNodeNum, decoded = null)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleTelemetry with empty payload bytes returns early`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = okio.ByteString.EMPTY),
|
||||
)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
// No crash — decodeOrNull returns null for empty payload
|
||||
}
|
||||
|
||||
// ---------- Battery notification: healthy battery does NOT trigger ----------
|
||||
|
||||
@Test
|
||||
fun `healthy battery level does not trigger low battery notification`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.0f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
val dataPacket = makeDataPacket(myNodeNum)
|
||||
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No dispatch call — battery is healthy
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,10 @@
|
|||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
|
|
@ -63,5 +65,7 @@ actual fun deleteDatabase(dbName: String) {
|
|||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates an Android DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
produceFile = { ContextServices.app.preferencesDataStoreFile(name) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ open class DatabaseManager(
|
|||
|
||||
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
|
||||
|
||||
// Expose the DB cache limit as a reactive stream so UI can observe changes.
|
||||
override val cacheLimit: StateFlow<Int> =
|
||||
datastore.data
|
||||
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
|
||||
|
|
@ -81,26 +80,35 @@ open class DatabaseManager(
|
|||
}
|
||||
}
|
||||
|
||||
private val dbCache = mutableMapOf<String, MeshtasticDatabase>()
|
||||
|
||||
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
|
||||
|
||||
/**
|
||||
* The currently active database, built lazily on first access. Room's `onOpen` callback is itself lazy (not invoked
|
||||
* until the first query), so construction only allocates the builder and connection pool — actual I/O is deferred.
|
||||
*/
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> =
|
||||
_currentDb
|
||||
.filterNotNull()
|
||||
.stateIn(
|
||||
managerScope,
|
||||
SharingStarted.Eagerly,
|
||||
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
|
||||
)
|
||||
.stateIn(managerScope, SharingStarted.Eagerly, getOrOpenDatabase(DatabaseConstants.DEFAULT_DB_NAME))
|
||||
|
||||
private val _currentAddress = MutableStateFlow<String?>(null)
|
||||
val currentAddress: StateFlow<String?> = _currentAddress
|
||||
|
||||
private val dbCache = mutableMapOf<String, MeshtasticDatabase>() // key = dbName
|
||||
|
||||
/** Initialize the active database for [address]. */
|
||||
suspend fun init(address: String?) {
|
||||
switchActiveDatabase(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached [MeshtasticDatabase] or builds a new one for [dbName]. The caller must hold [mutex] when
|
||||
* modifying [dbCache] concurrently; however, this helper is also used from [currentDb]'s `initialValue` where the
|
||||
* mutex is not yet relevant (single-threaded construction).
|
||||
*/
|
||||
private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase =
|
||||
dbCache.getOrPut(dbName) { getDatabaseBuilder(dbName).build() }
|
||||
|
||||
/** Switch active database to the one associated with [address]. Serialized via mutex. */
|
||||
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
|
||||
val dbName = buildDbName(address)
|
||||
|
|
@ -115,9 +123,11 @@ open class DatabaseManager(
|
|||
}
|
||||
|
||||
// Build/open Room DB off the main thread
|
||||
val db =
|
||||
dbCache[dbName]
|
||||
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
|
||||
val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) }
|
||||
|
||||
if (previousDbName != null && previousDbName != dbName) {
|
||||
closeCachedDatabase(previousDbName)
|
||||
}
|
||||
|
||||
_currentDb.value = db
|
||||
_currentAddress.value = address
|
||||
|
|
@ -134,6 +144,21 @@ open class DatabaseManager(
|
|||
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes and removes a cached database by name. Safe to call even if the database was already closed or not in the
|
||||
* cache. Does NOT delete the underlying file — the database can be re-opened on next access.
|
||||
*
|
||||
* On JVM/Desktop, Room KMP has no auto-close timeout (Android-only API), so idle databases hold open SQLite
|
||||
* connections (5 per WAL-mode DB) indefinitely until explicitly closed. This method is the primary mechanism for
|
||||
* releasing those connections when a database is no longer the active target.
|
||||
*/
|
||||
private fun closeCachedDatabase(dbName: String) {
|
||||
val removed = dbCache.remove(dbName) ?: return
|
||||
runCatching { removed.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close cached database ${anonymizeDbName(dbName)}" } }
|
||||
Logger.d { "Closed inactive database ${anonymizeDbName(dbName)} to free connections" }
|
||||
}
|
||||
|
||||
private val limitedIo = dispatchers.io.limitedParallelism(4)
|
||||
|
||||
/** Execute [block] with the current DB instance. */
|
||||
|
|
@ -184,9 +209,8 @@ open class DatabaseManager(
|
|||
val limit = getCurrentCacheLimit()
|
||||
val all = listExistingDbNames()
|
||||
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
|
||||
val deviceDbs = all.filterNot {
|
||||
it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME
|
||||
}
|
||||
val deviceDbs =
|
||||
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
|
||||
|
||||
if (deviceDbs.size <= limit) return@withLock
|
||||
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
|
||||
|
|
@ -194,12 +218,12 @@ open class DatabaseManager(
|
|||
|
||||
victims.forEach { name ->
|
||||
runCatching {
|
||||
dbCache.remove(name)?.close()
|
||||
closeCachedDatabase(name)
|
||||
deleteDatabase(name)
|
||||
datastore.edit { it.remove(lastUsedKey(name)) }
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to evict database $name" } }
|
||||
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
|
||||
.onSuccess { Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } }
|
||||
.onFailure { Logger.w(it) { "Failed to evict database ${anonymizeDbName(name)}" } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,11 +243,11 @@ open class DatabaseManager(
|
|||
|
||||
if (fs.exists(legacyPath)) {
|
||||
runCatching {
|
||||
dbCache.remove(legacy)?.close()
|
||||
closeCachedDatabase(legacy)
|
||||
deleteDatabase(legacy)
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
.onSuccess { Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } }
|
||||
.onFailure { Logger.w(it) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } }
|
||||
}
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
|
|
@ -31,8 +33,10 @@ import java.io.File
|
|||
/**
|
||||
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
|
||||
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
|
||||
*
|
||||
* Shared between `core:database` and `desktop` module to ensure all persistent data is co-located.
|
||||
*/
|
||||
private fun desktopDataDir(): String {
|
||||
fun desktopDataDir(): String {
|
||||
val override = System.getenv("MESHTASTIC_DATA_DIR")
|
||||
if (!override.isNullOrBlank()) return override
|
||||
return System.getProperty("user.home") + "/.meshtastic"
|
||||
|
|
@ -74,5 +78,8 @@ actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
|||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
File(dir).mkdirs()
|
||||
return PreferenceDataStoreFactory.create(produceFile = { File(dir, "$name.preferences_pb") })
|
||||
return PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
produceFile = { File(dir, "$name.preferences_pb") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,11 +56,15 @@ interface RadioController {
|
|||
suspend fun favoriteNode(nodeNum: Int)
|
||||
|
||||
/**
|
||||
* Sends our shared contact information (identity and public key) to a remote node.
|
||||
* Sends our shared contact information (identity and public key) to the firmware's NodeDB.
|
||||
*
|
||||
* This ensures the firmware has the correct public key for the destination node before a PKI-encrypted direct
|
||||
* message is sent. The method suspends until the radio acknowledges the admin packet.
|
||||
*
|
||||
* @param nodeNum The destination node number.
|
||||
* @return `true` if the radio accepted the contact, `false` on timeout or failure.
|
||||
*/
|
||||
suspend fun sendSharedContact(nodeNum: Int)
|
||||
suspend fun sendSharedContact(nodeNum: Int): Boolean
|
||||
|
||||
/**
|
||||
* Updates the local radio configuration.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.model.service
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
|
|
@ -32,5 +33,17 @@ sealed class ServiceAction {
|
|||
|
||||
data class ImportContact(val contact: SharedContact) : ServiceAction()
|
||||
|
||||
data class SendContact(val contact: SharedContact) : ServiceAction()
|
||||
/**
|
||||
* Sends a shared contact (identity + public key) to the firmware's NodeDB.
|
||||
*
|
||||
* The [result] deferred is completed with `true` when the radio acknowledges the admin packet, or `false` on
|
||||
* timeout/failure. Callers that need to guarantee the contact is stored before sending a subsequent DM should
|
||||
* `await()` this deferred.
|
||||
*
|
||||
* Not a data class: [result] is a [CompletableDeferred] with identity-based equality that would break data class
|
||||
* equals/hashCode/copy semantics.
|
||||
*/
|
||||
class SendContact(val contact: SharedContact) : ServiceAction() {
|
||||
val result: CompletableDeferred<Boolean> = CompletableDeferred()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ package org.meshtastic.core.network.radio
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
|
@ -36,7 +37,6 @@ import kotlinx.coroutines.job
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
|
|
@ -84,6 +84,7 @@ internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long {
|
|||
private const val CCCD_SETTLE_MS = 50L
|
||||
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
private val GATT_CLEANUP_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
|
||||
|
|
@ -157,7 +158,7 @@ class BleRadioInterface(
|
|||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning" }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
|
|
@ -169,7 +170,7 @@ class BleRadioInterface(
|
|||
}
|
||||
if (d != null) return d
|
||||
} catch (e: Exception) {
|
||||
Logger.v(e) { "Scan attempt failed or timed out" }
|
||||
Logger.v(e) { "[$address] Scan attempt failed or timed out" }
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
|
|
@ -182,106 +183,107 @@ class BleRadioInterface(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
private fun connect() {
|
||||
connectionJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Allow any pending background disconnects to complete and the Android BLE stack
|
||||
// to settle before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
val connectDelayMs = 1000L
|
||||
delay(connectDelayMs)
|
||||
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
val device = findDevice()
|
||||
|
||||
// Ensure the device is bonded before connecting. On Android, the
|
||||
// firmware may require an encrypted link (pairing mode != NO_PIN).
|
||||
// Without an explicit bond the GATT connection will fail with
|
||||
// insufficient-authentication (status 5) or the dreaded status 133.
|
||||
// On Desktop/JVM this is a no-op since the OS handles pairing during
|
||||
// the GATT connection when the peripheral requires it.
|
||||
if (!bluetoothRepository.isBonded(address)) {
|
||||
Logger.i { "[$address] Device not bonded, initiating bonding..." }
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
bluetoothRepository.bond(device)
|
||||
Logger.i { "[$address] Bonding successful" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
|
||||
}
|
||||
}
|
||||
|
||||
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
// Kable on Android occasionally fails the first connection attempt with
|
||||
// NotConnectedException if the previous peripheral wasn't fully cleaned
|
||||
// up by the OS. A quick retry resolves it.
|
||||
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
|
||||
connectionJob =
|
||||
connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Allow any pending background disconnects to complete and the Android BLE stack
|
||||
// to settle before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
delay(1500L)
|
||||
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
val connectDelayMs = 1000L
|
||||
delay(connectDelayMs)
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
// Connection succeeded — reset failure counter
|
||||
consecutiveFailures = 0
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
val device = findDevice()
|
||||
|
||||
// Use coroutineScope so that the connectionState listener is scoped to this
|
||||
// iteration only. When the inner scope exits (on disconnect), the listener is
|
||||
// cancelled automatically before the next reconnect cycle starts a fresh one.
|
||||
coroutineScope {
|
||||
bleConnection.connectionState
|
||||
.onEach { s ->
|
||||
if (s is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected()
|
||||
}
|
||||
// Ensure the device is bonded before connecting. On Android, the
|
||||
// firmware may require an encrypted link (pairing mode != NO_PIN).
|
||||
// Without an explicit bond the GATT connection will fail with
|
||||
// insufficient-authentication (status 5) or the dreaded status 133.
|
||||
// On Desktop/JVM this is a no-op since the OS handles pairing during
|
||||
// the GATT connection when the peripheral requires it.
|
||||
if (!bluetoothRepository.isBonded(address)) {
|
||||
Logger.i { "[$address] Device not bonded, initiating bonding" }
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
bluetoothRepository.bond(device)
|
||||
Logger.i { "[$address] Bonding successful" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
|
||||
}
|
||||
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" } }
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
// Suspend here until Kable drops the connection
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
// Kable on Android occasionally fails the first connection attempt with
|
||||
// NotConnectedException if the previous peripheral wasn't fully cleaned
|
||||
// up by the OS. A quick retry resolves it.
|
||||
Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" }
|
||||
@Suppress("MagicNumber")
|
||||
delay(1500L)
|
||||
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
// Connection succeeded — reset failure counter
|
||||
consecutiveFailures = 0
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
|
||||
// Use coroutineScope so that the connectionState listener is scoped to this
|
||||
// iteration only. When the inner scope exits (on disconnect), the listener is
|
||||
// cancelled automatically before the next reconnect cycle starts a fresh one.
|
||||
coroutineScope {
|
||||
bleConnection.connectionState
|
||||
.onEach { s ->
|
||||
if (s is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
|
||||
.launchIn(this)
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
// Suspend here until Kable drops the connection
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
}
|
||||
|
||||
Logger.i { "[$address] BLE connection dropped, preparing to reconnect" }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
consecutiveFailures++
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to connect to device after ${failureTime}ms " +
|
||||
"(consecutive failures: $consecutiveFailures)"
|
||||
}
|
||||
|
||||
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
|
||||
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
|
||||
// onDisconnect signals would reset upstream state machines unnecessarily.
|
||||
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
|
||||
handleFailure(e)
|
||||
}
|
||||
|
||||
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
|
||||
// Reduces BLE stack pressure and battery drain when the device is genuinely
|
||||
// out of range, while still recovering quickly from transient drops.
|
||||
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
|
||||
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
|
||||
delay(backoffMs)
|
||||
}
|
||||
|
||||
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
consecutiveFailures++
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to connect to device after ${failureTime}ms " +
|
||||
"(consecutive failures: $consecutiveFailures)"
|
||||
}
|
||||
|
||||
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
|
||||
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
|
||||
// onDisconnect signals would reset upstream state machines unnecessarily.
|
||||
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
|
||||
handleFailure(e)
|
||||
}
|
||||
|
||||
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
|
||||
// Reduces BLE stack pressure and battery drain when the device is genuinely
|
||||
// out of range, while still recovering quickly from transient drops.
|
||||
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
|
||||
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onConnected() {
|
||||
|
|
@ -304,8 +306,8 @@ class BleRadioInterface(
|
|||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected, " +
|
||||
Logger.i {
|
||||
"[$address] BLE disconnected - " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
|
|
@ -324,7 +326,7 @@ class BleRadioInterface(
|
|||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
|
|
@ -335,7 +337,7 @@ class BleRadioInterface(
|
|||
|
||||
radioService.logRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
|
|
@ -393,10 +395,9 @@ class BleRadioInterface(
|
|||
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
|
||||
packetsSent++
|
||||
bytesSent += p.size
|
||||
Logger.d {
|
||||
"[$address] Successfully wrote packet #$packetsSent " +
|
||||
"to toRadioCharacteristic - " +
|
||||
"${p.size} bytes (Total TX: $bytesSent bytes)"
|
||||
Logger.v {
|
||||
"[$address] Wrote packet #$packetsSent " +
|
||||
"to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) {
|
||||
|
|
@ -422,7 +423,7 @@ class BleRadioInterface(
|
|||
// Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
|
||||
// firmware's per-connection duplicate-write filter from silently dropping it.
|
||||
val nonce = heartbeatNonce.fetchAndAdd(1)
|
||||
Logger.d { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
|
||||
Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
|
||||
handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
|
||||
}
|
||||
|
||||
|
|
@ -437,19 +438,18 @@ class BleRadioInterface(
|
|||
}
|
||||
// Cancel the connection scope to break the while(isActive) reconnect loop.
|
||||
connectionScope.cancel("close() called")
|
||||
// GATT cleanup must run regardless of serviceScope lifecycle. SharedRadioInterfaceService
|
||||
// cancels serviceScope immediately after calling close(), so launching on serviceScope is
|
||||
// not reliable — the coroutine may never start. We use withContext(NonCancellable) inside
|
||||
// a serviceScope.launch to guarantee cleanup completes even if the scope is cancelled
|
||||
// mid-flight, preventing leaked BluetoothGatt objects (GATT 133 errors).
|
||||
// GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls
|
||||
// close() and then immediately cancels serviceScope — a coroutine launched on serviceScope
|
||||
// may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the
|
||||
// next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived,
|
||||
// fire-and-forget, and must outlive any application-managed scope.
|
||||
// onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly.
|
||||
serviceScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in close()" }
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in close()" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -457,9 +457,9 @@ class BleRadioInterface(
|
|||
private fun dispatchPacket(packet: ByteArray) {
|
||||
packetsReceived++
|
||||
bytesReceived += packet.size
|
||||
Logger.d {
|
||||
"[$address] Dispatching packet to service.handleFromRadio() - " +
|
||||
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
|
||||
Logger.v {
|
||||
"[$address] Dispatching packet #$packetsReceived " +
|
||||
"(${packet.size} bytes, total RX: $bytesReceived bytes)"
|
||||
}
|
||||
service.handleFromRadio(packet)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
@ -34,12 +33,14 @@ import java.io.OutputStream
|
|||
import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Shared JVM TCP transport for Meshtastic radios.
|
||||
*
|
||||
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff, heartbeat) and uses [StreamFrameCodec]
|
||||
* for the START1/START2 stream framing protocol.
|
||||
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the
|
||||
* START1/START2 stream framing protocol. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class
|
||||
* only exposes [sendHeartbeat] for external callers.
|
||||
*
|
||||
* Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
|
||||
*/
|
||||
|
|
@ -69,18 +70,24 @@ class TcpTransport(
|
|||
const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L
|
||||
const val SOCKET_TIMEOUT_MS = 5_000
|
||||
const val SOCKET_RETRIES = 18 // 18 * 5s = 90s inactivity before disconnect
|
||||
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
|
||||
const val TIMEOUT_LOG_INTERVAL = 5
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
}
|
||||
|
||||
private val codec = StreamFrameCodec(onPacketReceived = { listener.onPacketReceived(it) }, logTag = logTag)
|
||||
private val codec =
|
||||
StreamFrameCodec(
|
||||
onPacketReceived = {
|
||||
packetsReceived++
|
||||
listener.onPacketReceived(it)
|
||||
},
|
||||
logTag = logTag,
|
||||
)
|
||||
|
||||
// TCP socket state
|
||||
private var socket: Socket? = null
|
||||
private var outStream: OutputStream? = null
|
||||
private var connectionJob: Job? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var currentAddress: String? = null
|
||||
|
||||
// Metrics
|
||||
private var connectionStartTime: Long = 0
|
||||
|
|
@ -101,6 +108,7 @@ class TcpTransport(
|
|||
*/
|
||||
fun start(address: String) {
|
||||
stop()
|
||||
currentAddress = address
|
||||
connectionJob = scope.handledLaunch { connectWithRetry(address) }
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +117,7 @@ class TcpTransport(
|
|||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
disconnectSocket()
|
||||
currentAddress = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,14 +143,25 @@ class TcpTransport(
|
|||
var backoff = MIN_BACKOFF_MILLIS
|
||||
|
||||
while (retryCount <= MAX_RECONNECT_RETRIES) {
|
||||
try {
|
||||
connectAndRead(address)
|
||||
} catch (ex: IOException) {
|
||||
Logger.w { "$logTag: [$address] TCP connection error - ${ex.message}" }
|
||||
disconnectSocket()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
|
||||
Logger.e(ex) { "$logTag: [$address] TCP exception - ${ex.message}" }
|
||||
disconnectSocket()
|
||||
val hadData =
|
||||
try {
|
||||
connectAndRead(address)
|
||||
} catch (ex: IOException) {
|
||||
Logger.w { "$logTag: [$address] TCP connection error" }
|
||||
disconnectSocket()
|
||||
false
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) {
|
||||
Logger.e(ex) { "$logTag: [$address] TCP exception" }
|
||||
disconnectSocket()
|
||||
false
|
||||
}
|
||||
|
||||
// Reset backoff after a connection that successfully exchanged data,
|
||||
// so transient firmware-side disconnects recover quickly.
|
||||
if (hadData) {
|
||||
Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange" }
|
||||
retryCount = 1
|
||||
backoff = MIN_BACKOFF_MILLIS
|
||||
}
|
||||
|
||||
val delaySec = backoff / MILLIS_PER_SECOND
|
||||
|
|
@ -152,13 +172,17 @@ class TcpTransport(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the given address, read data until the connection is lost, and return whether any bytes were
|
||||
* successfully received (used by [connectWithRetry] to decide whether to reset backoff).
|
||||
*/
|
||||
@Suppress("NestedBlockDepth")
|
||||
private suspend fun connectAndRead(address: String) = withContext(dispatchers.io) {
|
||||
private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) {
|
||||
val parts = address.split(":", limit = 2)
|
||||
val host = parts[0]
|
||||
val port = parts.getOrNull(1)?.toIntOrNull() ?: StreamFrameCodec.DEFAULT_TCP_PORT
|
||||
|
||||
Logger.i { "$logTag: [$address] Connecting to $host:$port..." }
|
||||
Logger.i { "$logTag: [$address] Connecting to $host:$port" }
|
||||
val attemptStart = nowMillis
|
||||
|
||||
Socket(InetAddress.getByName(host), port).use { sock ->
|
||||
|
|
@ -181,7 +205,6 @@ class TcpTransport(
|
|||
// Send wake bytes and signal connected
|
||||
sendBytesRaw(StreamFrameCodec.WAKE_BYTES)
|
||||
listener.onConnected()
|
||||
startHeartbeat(address)
|
||||
|
||||
// Read loop
|
||||
var timeoutCount = 0
|
||||
|
|
@ -189,7 +212,7 @@ class TcpTransport(
|
|||
try {
|
||||
val c = input.read()
|
||||
if (c == -1) {
|
||||
Logger.w { "$logTag: [$address] EOF after $packetsReceived packets" }
|
||||
Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" }
|
||||
break
|
||||
}
|
||||
timeoutCount = 0
|
||||
|
|
@ -209,27 +232,25 @@ class TcpTransport(
|
|||
}
|
||||
}
|
||||
}
|
||||
val hadData = bytesReceived > 0
|
||||
disconnectSocket()
|
||||
hadData
|
||||
}
|
||||
}
|
||||
|
||||
// Guards against recursive disconnects triggered by listener callbacks.
|
||||
private var isDisconnecting: Boolean = false
|
||||
private val isDisconnecting = AtomicBoolean(false)
|
||||
|
||||
private fun disconnectSocket() {
|
||||
if (isDisconnecting) return
|
||||
if (!isDisconnecting.compareAndSet(false, true)) return
|
||||
|
||||
isDisconnecting = true
|
||||
try {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
|
||||
val s = socket
|
||||
val hadConnection = s != null || outStream != null
|
||||
if (s != null) {
|
||||
val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
|
||||
Logger.i {
|
||||
"$logTag: Disconnecting - Uptime: ${uptime}ms, " +
|
||||
"$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " +
|
||||
"RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
|
|
@ -247,7 +268,7 @@ class TcpTransport(
|
|||
listener.onDisconnected()
|
||||
}
|
||||
} finally {
|
||||
isDisconnecting = false
|
||||
isDisconnecting.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +280,7 @@ class TcpTransport(
|
|||
val stream =
|
||||
outStream
|
||||
?: run {
|
||||
Logger.w { "$logTag: Cannot send ${p.size} bytes: not connected" }
|
||||
Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" }
|
||||
return
|
||||
}
|
||||
packetsSent++
|
||||
|
|
@ -267,7 +288,7 @@ class TcpTransport(
|
|||
try {
|
||||
stream.write(p)
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: TCP write error: ${ex.message}" }
|
||||
Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" }
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
|
|
@ -277,28 +298,13 @@ class TcpTransport(
|
|||
try {
|
||||
stream.flush()
|
||||
} catch (ex: IOException) {
|
||||
Logger.w(ex) { "$logTag: TCP flush error: ${ex.message}" }
|
||||
Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" }
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Heartbeat
|
||||
|
||||
private fun startHeartbeat(address: String) {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = scope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
Logger.d { "$logTag: [$address] Sending heartbeat" }
|
||||
sendHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun resetMetrics() {
|
||||
packetsReceived = 0
|
||||
packetsSent = 0
|
||||
|
|
|
|||
|
|
@ -25,12 +25,17 @@ import kotlinx.coroutines.isActive
|
|||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.network.radio.StreamInterface
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet
|
||||
* framing.
|
||||
*
|
||||
* Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read
|
||||
* loop is started.
|
||||
*/
|
||||
class SerialTransport(
|
||||
class SerialTransport
|
||||
private constructor(
|
||||
private val portName: String,
|
||||
private val baudRate: Int = DEFAULT_BAUD_RATE,
|
||||
service: RadioInterfaceService,
|
||||
|
|
@ -39,7 +44,7 @@ class SerialTransport(
|
|||
private var readJob: Job? = null
|
||||
|
||||
/** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
|
||||
fun startConnection(): Boolean {
|
||||
private fun startConnection(): Boolean {
|
||||
return try {
|
||||
val port = SerialPort.getCommPort(portName) ?: return false
|
||||
port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
|
||||
|
|
@ -48,20 +53,23 @@ class SerialTransport(
|
|||
serialPort = port
|
||||
port.setDTR()
|
||||
port.setRTS()
|
||||
Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
|
||||
super.connect() // Sends WAKE_BYTES and signals service.onConnect()
|
||||
startReadLoop(port)
|
||||
true
|
||||
} else {
|
||||
Logger.w { "[$portName] Serial port openPort() returned false" }
|
||||
false
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Serial connection failed" }
|
||||
Logger.w(e) { "[$portName] Serial connection failed" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun startReadLoop(port: SerialPort) {
|
||||
Logger.d { "[$portName] Starting serial read loop" }
|
||||
readJob =
|
||||
service.serviceScope.launch(Dispatchers.IO) {
|
||||
val input = port.inputStream
|
||||
|
|
@ -84,9 +92,9 @@ class SerialTransport(
|
|||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
if (isActive) {
|
||||
Logger.e(e) { "Serial read IOException: ${e.message}" }
|
||||
Logger.w(e) { "[$portName] Serial read error" }
|
||||
} else {
|
||||
Logger.d { "Serial read interrupted by cancellation: ${e.message}" }
|
||||
Logger.d { "[$portName] Serial read interrupted by cancellation" }
|
||||
}
|
||||
reading = false
|
||||
}
|
||||
|
|
@ -95,11 +103,12 @@ class SerialTransport(
|
|||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
if (isActive) {
|
||||
Logger.e(e) { "Serial read loop outer error: ${e.message}" }
|
||||
Logger.w(e) { "[$portName] Serial read loop outer error" }
|
||||
} else {
|
||||
Logger.d { "Serial read loop outer interrupted by cancellation: ${e.message}" }
|
||||
Logger.d { "[$portName] Serial read loop interrupted by cancellation" }
|
||||
}
|
||||
} finally {
|
||||
Logger.d { "[$portName] Serial read loop exiting" }
|
||||
try {
|
||||
input.close()
|
||||
} catch (_: Exception) {
|
||||
|
|
@ -137,6 +146,7 @@ class SerialTransport(
|
|||
}
|
||||
|
||||
override fun close() {
|
||||
Logger.d { "[$portName] Closing serial transport" }
|
||||
readJob?.cancel()
|
||||
readJob = null
|
||||
closePortResources()
|
||||
|
|
@ -149,10 +159,64 @@ class SerialTransport(
|
|||
private const val READ_BUFFER_SIZE = 1024
|
||||
private const val READ_TIMEOUT_MS = 100
|
||||
|
||||
/**
|
||||
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
|
||||
* disconnect to the [service] and returns the (non-connected) instance.
|
||||
*/
|
||||
fun open(portName: String, baudRate: Int = DEFAULT_BAUD_RATE, service: RadioInterfaceService): SerialTransport {
|
||||
val transport = SerialTransport(portName, baudRate, service)
|
||||
if (!transport.startConnection()) {
|
||||
val errorMessage = diagnoseOpenFailure(portName)
|
||||
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
|
||||
service.onDisconnect(isPermanent = true, errorMessage = errorMessage)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
|
||||
* "COM3", "/dev/ttyUSB0").
|
||||
*/
|
||||
fun getAvailablePorts(): List<String> = SerialPort.getCommPorts().map { it.systemPortName }
|
||||
|
||||
/**
|
||||
* Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks
|
||||
* file permissions and suggests the appropriate group fix.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private fun diagnoseOpenFailure(portName: String): String {
|
||||
val osName = System.getProperty("os.name", "").lowercase()
|
||||
if (!osName.contains("linux")) {
|
||||
return "Could not open serial port: $portName"
|
||||
}
|
||||
|
||||
// jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0"
|
||||
val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
|
||||
val portFile = File(devPath)
|
||||
if (!portFile.exists()) {
|
||||
return "Serial port $portName not found. Is the device still connected?"
|
||||
}
|
||||
if (!portFile.canRead() || !portFile.canWrite()) {
|
||||
val group = detectSerialGroup(devPath)
|
||||
val user = System.getProperty("user.name", "your_user")
|
||||
return "Permission denied for $devPath. " +
|
||||
"Run: sudo usermod -aG $group $user — then log out and back in."
|
||||
}
|
||||
return "Could not open serial port: $portName"
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu
|
||||
* default) if detection fails.
|
||||
*/
|
||||
@Suppress("SwallowedException", "TooGenericExceptionCaught")
|
||||
private fun detectSerialGroup(devPath: String): String = try {
|
||||
val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
|
||||
val group = process.inputStream.bufferedReader().readText().trim()
|
||||
process.waitFor()
|
||||
group.ifEmpty { "dialout" }
|
||||
} catch (e: Exception) {
|
||||
"dialout"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling admin messages from the mesh (config, metadata, session passkey). */
|
||||
interface AdminPacketHandler {
|
||||
/**
|
||||
* Processes an admin message packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int)
|
||||
}
|
||||
|
|
@ -56,6 +56,21 @@ interface CommandSender {
|
|||
initFn: () -> AdminMessage,
|
||||
)
|
||||
|
||||
/**
|
||||
* Sends an admin message and suspends until the radio acknowledges it.
|
||||
*
|
||||
* This is used when the caller needs to guarantee a packet has been accepted by the radio before proceeding, such
|
||||
* as sending a shared contact before the first DM to a node.
|
||||
*
|
||||
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
|
||||
*/
|
||||
suspend fun sendAdminAwait(
|
||||
destNum: Int,
|
||||
requestId: Int = generatePacketId(),
|
||||
wantResponse: Boolean = false,
|
||||
initFn: () -> AdminMessage,
|
||||
): Boolean
|
||||
|
||||
/** Sends our current position to the mesh. */
|
||||
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ interface MeshActionHandler {
|
|||
fun start(scope: CoroutineScope)
|
||||
|
||||
/** Processes a service action from the UI. */
|
||||
fun onServiceAction(action: ServiceAction)
|
||||
suspend fun onServiceAction(action: ServiceAction)
|
||||
|
||||
/** Sets the owner of the local node. */
|
||||
fun handleSetOwner(u: MeshUser, myNodeNum: Int)
|
||||
|
|
|
|||
|
|
@ -54,8 +54,11 @@ interface NodeManager : NodeIdLookup {
|
|||
/** Starts the node manager with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/** The local node number. */
|
||||
var myNodeNum: Int?
|
||||
/** The local node number as a thread-safe [StateFlow]. */
|
||||
val myNodeNum: StateFlow<Int?>
|
||||
|
||||
/** Sets the local node number. */
|
||||
fun setMyNodeNum(num: Int?)
|
||||
|
||||
/** Loads the cached node database from the repository. */
|
||||
fun loadCachedNodeDB()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ interface PacketHandler {
|
|||
/** Adds a mesh packet to the queue for sending. */
|
||||
fun sendToRadio(packet: MeshPacket)
|
||||
|
||||
/**
|
||||
* Adds a mesh packet to the queue and suspends until the radio acknowledges it via [QueueStatus].
|
||||
*
|
||||
* Unlike [sendToRadio], which is fire-and-forget, this method provides back-pressure so the caller can ensure a
|
||||
* packet has been accepted by the radio before proceeding. This is critical for operations where ordering matters
|
||||
* (e.g., sending a shared contact before the first DM).
|
||||
*
|
||||
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
|
||||
*/
|
||||
suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean
|
||||
|
||||
/** Processes queue status updates from the radio. */
|
||||
fun handleQueueStatus(queueStatus: QueueStatus)
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ interface RadioInterfaceService {
|
|||
/** Called by an interface when it has received raw data from the radio. */
|
||||
fun handleFromRadio(bytes: ByteArray)
|
||||
|
||||
/** Flow of user-facing connection error messages (e.g. permission failures). */
|
||||
val connectionError: SharedFlow<String>
|
||||
|
||||
/** The scope in which interface-related coroutines should run. */
|
||||
val serviceScope: CoroutineScope
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling telemetry packets from the mesh, including battery notifications. */
|
||||
interface TelemetryPacketHandler {
|
||||
/** Starts the handler with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/**
|
||||
* Processes a telemetry packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param dataPacket The decoded data packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
|
||||
}
|
||||
|
|
@ -130,7 +130,10 @@ class SendMessageUseCaseImpl(
|
|||
|
||||
private suspend fun sendSharedContact(node: Node) {
|
||||
try {
|
||||
radioController.sendSharedContact(node.num)
|
||||
val accepted = radioController.sendSharedContact(node.num)
|
||||
if (!accepted) {
|
||||
Logger.w { "Shared contact for node ${node.num} was not acknowledged by the radio" }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Send shared contact error" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ import android.app.Application
|
|||
import androidx.core.location.LocationCompat
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
|
|
@ -37,7 +35,7 @@ import org.meshtastic.proto.Position as ProtoPosition
|
|||
@Single
|
||||
class AndroidMeshLocationManager(private val context: Application, private val locationRepository: LocationRepository) :
|
||||
MeshLocationManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private lateinit var scope: CoroutineScope
|
||||
private var locationFlow: Job? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class AndroidRadioControllerImpl(
|
|||
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
|
||||
}
|
||||
|
||||
override suspend fun sendSharedContact(nodeNum: Int) {
|
||||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
val nodeDef = nodeRepository.getNode(nodeNum.toString())
|
||||
val contact =
|
||||
org.meshtastic.proto.SharedContact(
|
||||
|
|
@ -62,7 +62,9 @@ class AndroidRadioControllerImpl(
|
|||
user = nodeDef.user,
|
||||
manually_verified = nodeDef.manuallyVerified,
|
||||
)
|
||||
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
|
||||
val action = ServiceAction.SendContact(contact)
|
||||
serviceRepository.onServiceAction(action)
|
||||
return action.result.await()
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import org.meshtastic.core.model.MyNodeInfo
|
|||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
|
|
@ -73,7 +74,7 @@ class MeshService : Service() {
|
|||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
|
||||
private val myNodeNum: Int
|
||||
get() = nodeManager.myNodeNum ?: throw RadioNotConnectedException()
|
||||
get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException()
|
||||
|
||||
companion object {
|
||||
fun actionReceived(portNum: Int): String {
|
||||
|
|
@ -98,11 +99,11 @@ class MeshService : Service() {
|
|||
try {
|
||||
super.onCreate()
|
||||
} catch (e: IllegalStateException) {
|
||||
// Hilt can throw IllegalStateException in tests if the component is not created.
|
||||
// Koin can throw IllegalStateException in tests if the component is not created.
|
||||
// This can happen if the service is started by the system (e.g. after a crash or on boot)
|
||||
// before the test rule has a chance to create the component.
|
||||
if (e.message?.contains("HiltAndroidRule") == true) {
|
||||
Logger.w(e) { "MeshService created before Hilt component was ready in test. Stopping service." }
|
||||
if (e.message?.contains("HiltAndroidRule") == true || e.message?.contains("Koin") == true) {
|
||||
Logger.w(e) { "MeshService created before DI component was ready in test, stopping service" }
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
|
@ -188,7 +189,7 @@ class MeshService : Service() {
|
|||
object : IMeshService.Stub() {
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
|
||||
Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." }
|
||||
Logger.d { "Passing through device change to radio service: ${deviceAddr?.anonymize}" }
|
||||
router.actionHandler.handleUpdateLastAddress(deviceAddr)
|
||||
radioInterfaceService.setDeviceAddress(deviceAddr)
|
||||
}
|
||||
|
|
@ -300,7 +301,7 @@ class MeshService : Service() {
|
|||
}
|
||||
|
||||
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
|
||||
val myNodeNum = nodeManager.myNodeNum
|
||||
val myNodeNum = nodeManager.myNodeNum.value
|
||||
if (myNodeNum != null) {
|
||||
router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class DirectRadioControllerImpl(
|
|||
get() = router.actionHandler
|
||||
|
||||
private val myNodeNum: Int
|
||||
get() = nodeManager.myNodeNum ?: 0
|
||||
get() = nodeManager.myNodeNum.value ?: 0
|
||||
|
||||
override val connectionState: StateFlow<ConnectionState>
|
||||
get() = serviceRepository.connectionState
|
||||
|
|
@ -82,11 +82,13 @@ class DirectRadioControllerImpl(
|
|||
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
|
||||
}
|
||||
|
||||
override suspend fun sendSharedContact(nodeNum: Int) {
|
||||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
val nodeDef = nodeRepository.getNode(nodeNum.toString())
|
||||
val contact =
|
||||
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
|
||||
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
|
||||
val action = ServiceAction.SendContact(contact)
|
||||
serviceRepository.onServiceAction(action)
|
||||
return action.result.await()
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: Config) {
|
||||
|
|
@ -178,7 +180,7 @@ class DirectRadioControllerImpl(
|
|||
}
|
||||
|
||||
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
|
||||
val myNode = nodeManager.myNodeNum
|
||||
val myNode = nodeManager.myNodeNum.value
|
||||
if (myNode != null) {
|
||||
actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@
|
|||
package org.meshtastic.core.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
|
|
@ -60,6 +62,7 @@ class MeshServiceOrchestrator(
|
|||
private val takMeshIntegration: TAKMeshIntegration,
|
||||
private val takPrefs: TakPrefs,
|
||||
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
private val databaseManager: DatabaseManager,
|
||||
) {
|
||||
private var serviceJob: Job? = null
|
||||
private var takJob: Job? = null
|
||||
|
|
@ -80,7 +83,7 @@ class MeshServiceOrchestrator(
|
|||
*/
|
||||
fun start() {
|
||||
if (isRunning) {
|
||||
Logger.w { "MeshServiceOrchestrator.start() called while already running" }
|
||||
Logger.d { "start() called while already running, ignoring" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -104,22 +107,41 @@ class MeshServiceOrchestrator(
|
|||
takPrefs.isTakServerEnabled
|
||||
.onEach { isEnabled ->
|
||||
if (isEnabled && !takServerManager.isRunning.value) {
|
||||
Logger.i { "TAK Server enabled by preference, starting integration..." }
|
||||
Logger.i { "TAK Server enabled by preference, starting integration" }
|
||||
takMeshIntegration.start(scope)
|
||||
} else if (!isEnabled && takServerManager.isRunning.value) {
|
||||
Logger.i { "TAK Server disabled by preference, stopping integration..." }
|
||||
Logger.i { "TAK Server disabled by preference, stopping integration" }
|
||||
takMeshIntegration.stop()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
scope.handledLaunch { radioInterfaceService.connect() }
|
||||
scope.handledLaunch {
|
||||
// Ensure the per-device database is active before the radio connects.
|
||||
// On Android this is handled by MeshUtilApplication.init(); on Desktop (and any
|
||||
// future KMP host) the orchestrator is the first entry point, so it must initialize
|
||||
// the database here. Without this, DatabaseManager._currentDb stays null and all
|
||||
// Room writes via withDb() are silently dropped — causing ourNodeInfo to remain null
|
||||
// after the handshake completes.
|
||||
databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress())
|
||||
Logger.i { "Per-device database initialized, connecting radio" }
|
||||
radioInterfaceService.connect()
|
||||
}
|
||||
|
||||
radioInterfaceService.receivedData
|
||||
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
|
||||
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) }
|
||||
.launchIn(scope)
|
||||
|
||||
serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(scope)
|
||||
radioInterfaceService.connectionError
|
||||
.onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) }
|
||||
.launchIn(scope)
|
||||
|
||||
// Each action is dispatched in its own supervised coroutine so that a failure in one
|
||||
// action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently
|
||||
// drop all subsequent service actions for the rest of the session.
|
||||
serviceRepository.serviceAction
|
||||
.onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } }
|
||||
.launchIn(scope)
|
||||
|
||||
nodeManager.loadCachedNodeDB()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class SharedRadioInterfaceService(
|
|||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
override val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
|
@ -142,7 +142,7 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
|
|
@ -155,7 +155,7 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ class SharedRadioInterfaceService(
|
|||
val address = getBondedDeviceAddress()
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No valid address to connect to." }
|
||||
Logger.d { "No valid address to connect to" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -245,12 +245,13 @@ class SharedRadioInterfaceService(
|
|||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
|
|
@ -273,16 +274,18 @@ class SharedRadioInterfaceService(
|
|||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
Logger.e(t) { "handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
// MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than
|
||||
// launching a coroutine. The async launch pattern introduced a window where a concurrent
|
||||
// onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck
|
||||
// in Connected while the transport was actually disconnected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.d { "Broadcasting connection state change to Connected" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
_connectionState.emit(ConnectionState.Connected)
|
||||
}
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +296,7 @@ class SharedRadioInterfaceService(
|
|||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
Logger.d { "Broadcasting connection state change to $newTargetState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newTargetState) }
|
||||
_connectionState.value = newTargetState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,24 @@
|
|||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import co.touchlab.kermit.Severity
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode.Companion.exactly
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshActionHandler
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshMessageProcessor
|
||||
|
|
@ -57,25 +64,35 @@ class MeshServiceOrchestratorTest {
|
|||
private val commandSender: CommandSender = mock(MockMode.autofill)
|
||||
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
||||
private val router: MeshRouter = mock(MockMode.autofill)
|
||||
private val actionHandler: MeshActionHandler = mock(MockMode.autofill)
|
||||
private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
|
||||
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
|
||||
private val takPrefs: TakPrefs = mock(MockMode.autofill)
|
||||
private val cotHandler: CoTHandler = mock(MockMode.autofill)
|
||||
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
|
||||
|
||||
@Test
|
||||
fun testStartWiresComponents() {
|
||||
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
|
||||
every { serviceRepository.serviceAction } returns MutableSharedFlow()
|
||||
/** Stubs the shared flow dependencies used by every test and returns an orchestrator. */
|
||||
private fun createOrchestrator(
|
||||
receivedData: MutableSharedFlow<ByteArray> = MutableSharedFlow(),
|
||||
connectionError: MutableSharedFlow<String> = MutableSharedFlow(),
|
||||
serviceAction: MutableSharedFlow<ServiceAction> = MutableSharedFlow(),
|
||||
takEnabledFlow: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
takRunningFlow: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
): MeshServiceOrchestrator {
|
||||
every { radioInterfaceService.receivedData } returns receivedData
|
||||
every { radioInterfaceService.connectionError } returns connectionError
|
||||
every { serviceRepository.serviceAction } returns serviceAction
|
||||
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
|
||||
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
|
||||
every { takPrefs.isTakServerEnabled } returns MutableStateFlow(false)
|
||||
every { takServerManager.isRunning } returns MutableStateFlow(false)
|
||||
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
|
||||
every { takServerManager.isRunning } returns takRunningFlow
|
||||
every { takServerManager.inboundMessages } returns MutableSharedFlow()
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { router.actionHandler } returns actionHandler
|
||||
|
||||
val takMeshIntegration =
|
||||
TAKMeshIntegration(
|
||||
|
|
@ -87,22 +104,27 @@ class MeshServiceOrchestratorTest {
|
|||
cotHandler = cotHandler,
|
||||
)
|
||||
|
||||
val orchestrator =
|
||||
MeshServiceOrchestrator(
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
serviceRepository = serviceRepository,
|
||||
packetHandler = packetHandler,
|
||||
nodeManager = nodeManager,
|
||||
messageProcessor = messageProcessor,
|
||||
commandSender = commandSender,
|
||||
connectionManager = connectionManager,
|
||||
router = router,
|
||||
serviceNotifications = serviceNotifications,
|
||||
takServerManager = takServerManager,
|
||||
takMeshIntegration = takMeshIntegration,
|
||||
takPrefs = takPrefs,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
return MeshServiceOrchestrator(
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
serviceRepository = serviceRepository,
|
||||
packetHandler = packetHandler,
|
||||
nodeManager = nodeManager,
|
||||
messageProcessor = messageProcessor,
|
||||
commandSender = commandSender,
|
||||
connectionManager = connectionManager,
|
||||
router = router,
|
||||
serviceNotifications = serviceNotifications,
|
||||
takServerManager = takServerManager,
|
||||
takMeshIntegration = takMeshIntegration,
|
||||
takPrefs = takPrefs,
|
||||
dispatchers = dispatchers,
|
||||
databaseManager = databaseManager,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStartWiresComponents() {
|
||||
val orchestrator = createOrchestrator()
|
||||
|
||||
assertFalse(orchestrator.isRunning)
|
||||
orchestrator.start()
|
||||
|
|
@ -121,41 +143,7 @@ class MeshServiceOrchestratorTest {
|
|||
val takEnabledFlow = MutableStateFlow(false)
|
||||
val takRunningFlow = MutableStateFlow(false)
|
||||
|
||||
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
|
||||
every { serviceRepository.serviceAction } returns MutableSharedFlow()
|
||||
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
|
||||
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
|
||||
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
|
||||
every { takServerManager.isRunning } returns takRunningFlow
|
||||
every { takServerManager.inboundMessages } returns MutableSharedFlow()
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
|
||||
val takMeshIntegration =
|
||||
TAKMeshIntegration(
|
||||
takServerManager = takServerManager,
|
||||
commandSender = commandSender,
|
||||
nodeRepository = nodeRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
meshConfigHandler = meshConfigHandler,
|
||||
cotHandler = cotHandler,
|
||||
)
|
||||
|
||||
val orchestrator =
|
||||
MeshServiceOrchestrator(
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
serviceRepository = serviceRepository,
|
||||
packetHandler = packetHandler,
|
||||
nodeManager = nodeManager,
|
||||
messageProcessor = messageProcessor,
|
||||
commandSender = commandSender,
|
||||
connectionManager = connectionManager,
|
||||
router = router,
|
||||
serviceNotifications = serviceNotifications,
|
||||
takServerManager = takServerManager,
|
||||
takMeshIntegration = takMeshIntegration,
|
||||
takPrefs = takPrefs,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
|
||||
|
||||
orchestrator.start()
|
||||
|
||||
|
|
@ -172,4 +160,67 @@ class MeshServiceOrchestratorTest {
|
|||
|
||||
orchestrator.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStartCallsSwitchActiveDatabase() {
|
||||
every { radioInterfaceService.getDeviceAddress() } returns "tcp:192.168.1.100"
|
||||
|
||||
val orchestrator = createOrchestrator()
|
||||
orchestrator.start()
|
||||
|
||||
verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.100") }
|
||||
verify { radioInterfaceService.connect() }
|
||||
|
||||
orchestrator.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectionErrorForwardedToServiceRepository() {
|
||||
val connectionError = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
|
||||
val orchestrator = createOrchestrator(connectionError = connectionError)
|
||||
orchestrator.start()
|
||||
|
||||
// Emit an error into the radio interface's connectionError flow
|
||||
connectionError.tryEmit("BLE connection lost")
|
||||
|
||||
verify { serviceRepository.setErrorMessage("BLE connection lost", Severity.Warn) }
|
||||
|
||||
orchestrator.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testServiceActionDispatchedToActionHandler() {
|
||||
val serviceAction = MutableSharedFlow<ServiceAction>(extraBufferCapacity = 1)
|
||||
|
||||
val orchestrator = createOrchestrator(serviceAction = serviceAction)
|
||||
orchestrator.start()
|
||||
|
||||
val action = ServiceAction.Favorite(Node(num = 42))
|
||||
serviceAction.tryEmit(action)
|
||||
|
||||
verifySuspend { actionHandler.onServiceAction(action) }
|
||||
|
||||
orchestrator.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStartIsIdempotent() {
|
||||
val orchestrator = createOrchestrator()
|
||||
|
||||
orchestrator.start()
|
||||
assertTrue(orchestrator.isRunning)
|
||||
|
||||
// Second call should be a no-op
|
||||
orchestrator.start()
|
||||
assertTrue(orchestrator.isRunning)
|
||||
|
||||
// Components should only be initialized once
|
||||
verify(exactly(1)) { serviceNotifications.initChannels() }
|
||||
verify(exactly(1)) { packetHandler.start(any()) }
|
||||
verify(exactly(1)) { nodeManager.loadCachedNodeDB() }
|
||||
|
||||
orchestrator.stop()
|
||||
assertFalse(orchestrator.isRunning)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,8 +73,9 @@ class FakeRadioController :
|
|||
favoritedNodes.add(nodeNum)
|
||||
}
|
||||
|
||||
override suspend fun sendSharedContact(nodeNum: Int) {
|
||||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
sentSharedContacts.add(nodeNum)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
|
|||
private val _meshActivity = MutableSharedFlow<MeshActivity>()
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>()
|
||||
override val connectionError: SharedFlow<String> = _connectionError
|
||||
|
||||
val sentToRadio = mutableListOf<ByteArray>()
|
||||
var connectCalled = false
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import okio.Path.Companion.toPath
|
|||
import org.jetbrains.skia.Image
|
||||
import org.koin.core.context.startKoin
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.rememberMultiBackstack
|
||||
|
|
@ -248,7 +249,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
},
|
||||
) {
|
||||
setSingletonImageLoaderFactory { context ->
|
||||
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
|
||||
val cacheDir = desktopDataDir() + "/image_cache_v3"
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(KtorNetworkFetcherFactory(httpClient = httpClient))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import okio.Path.Companion.toPath
|
|||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.database.desktopDataDir
|
||||
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
|
||||
|
|
@ -43,16 +44,6 @@ import org.meshtastic.proto.LocalConfig
|
|||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
|
||||
/**
|
||||
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
|
||||
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
|
||||
*/
|
||||
private fun desktopDataDir(): String {
|
||||
val override = System.getenv("MESHTASTIC_DATA_DIR")
|
||||
if (!override.isNullOrBlank()) return override
|
||||
return System.getProperty("user.home") + "/.meshtastic"
|
||||
}
|
||||
|
||||
/** Creates a file-backed [DataStore]<[Preferences]> at the given path under the data directory. */
|
||||
private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
|
|
@ -90,7 +81,14 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
|
|||
*/
|
||||
@Suppress("InjectDispatcher")
|
||||
fun desktopPlatformModule() = module {
|
||||
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
|
||||
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
|
||||
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
|
||||
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
|
||||
// DataStore has no close() API — the in-memory cache is released only when this Job is cancelled
|
||||
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
|
||||
val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope))
|
||||
|
||||
// -- Build config --
|
||||
single<BuildConfigProvider> {
|
||||
|
|
@ -109,10 +107,7 @@ fun desktopPlatformModule() = module {
|
|||
}
|
||||
|
||||
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
|
||||
@Suppress("InjectDispatcher")
|
||||
private fun desktopPreferencesDataStoreModule() = module {
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module {
|
||||
single<DataStore<Preferences>>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
|
||||
single<DataStore<Preferences>>(named("HomoglyphEncodingDataStore")) {
|
||||
createPreferencesDataStore("homoglyph_encoding", scope)
|
||||
|
|
@ -135,9 +130,7 @@ private fun desktopPreferencesDataStoreModule() = module {
|
|||
}
|
||||
|
||||
/** Proto [DataStore] instances (OkioStorage-backed). */
|
||||
@Suppress("InjectDispatcher")
|
||||
private fun desktopProtoDataStoreModule() = module {
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
|
||||
val protoDir = desktopDataDir() + "/datastore"
|
||||
|
||||
single<DataStore<LocalConfig>>(named("CoreLocalConfigDataStore")) {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ class DesktopRadioTransportFactory(
|
|||
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
|
||||
}
|
||||
address.startsWith(InterfaceId.SERIAL.id) -> {
|
||||
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
|
||||
SerialTransport.open(
|
||||
portName = address.removePrefix(InterfaceId.SERIAL.id.toString()),
|
||||
service = service,
|
||||
)
|
||||
}
|
||||
else -> error("Unsupported transport for address: $address")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class NoopRadioInterfaceService : RadioInterfaceService {
|
|||
|
||||
override val receivedData = MutableSharedFlow<ByteArray>()
|
||||
override val meshActivity = MutableSharedFlow<MeshActivity>()
|
||||
override val connectionError = MutableSharedFlow<String>()
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)")
|
||||
|
|
|
|||
|
|
@ -225,6 +225,27 @@ Ordered by impact × effort:
|
|||
|
||||
---
|
||||
|
||||
## F. JVM/Desktop Database Lifecycle
|
||||
|
||||
Room KMP's `setAutoCloseTimeout` API is Android-only. On JVM/Desktop, once a Room database is built, its SQLite connections (5 per WAL-mode DB: 4 readers + 1 writer) remain open indefinitely until explicitly closed via `RoomDatabase.close()`.
|
||||
|
||||
### Problem
|
||||
|
||||
When a user switches between multiple mesh devices, the previous device's database remained open in the in-memory cache. Each idle database consumed ~32 MB (connection pool + prepared statement caches), leading to unbounded memory growth proportional to the number of devices ever connected in a session.
|
||||
|
||||
### Solution
|
||||
|
||||
`DatabaseManager.switchActiveDatabase()` now explicitly closes the previously active database via `closeCachedDatabase()` before activating the new one. The closed database is removed from the in-memory cache but its file is preserved, allowing transparent re-opening on next access.
|
||||
|
||||
Additional fixes applied:
|
||||
1. **Init-order bug**: `dbCache` was declared after `currentDb`, causing NPE during `stateIn`'s `initialValue` evaluation. Reordered to ensure `dbCache` is initialized first.
|
||||
2. **Corruption handlers**: `ReplaceFileCorruptionHandler` added to `createDatabaseDataStore()` on both JVM and Android, preventing DataStore corruption from crashing the app.
|
||||
3. **`desktopDataDir()` deduplication**: Made public in `core:database/jvmMain` and removed the duplicate from `DesktopPlatformModule`, establishing a single source of truth for the desktop data directory.
|
||||
4. **DataStore scope consolidation**: Replaced two separate `CoroutineScope` instances with a single shared `dataStoreScope` in `DesktopPlatformModule`.
|
||||
5. **Coil cache path**: Desktop `Main.kt` updated to use `desktopDataDir()` instead of hardcoded `user.home`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Current migration status: [`kmp-status.md`](./kmp-status.md)
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to
|
|||
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
|
||||
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
|
||||
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
|
||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. |
|
||||
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
||||
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ fun ConnectionsScreen(
|
|||
|
||||
1 ->
|
||||
ConnectingDeviceContent(
|
||||
connectionState = connectionState,
|
||||
selectedDevice = selectedDevice,
|
||||
persistedDeviceName = persistedDeviceName,
|
||||
bleDevices = bleDevices,
|
||||
|
|
@ -328,6 +329,7 @@ private fun ConnectedDeviceContent(
|
|||
/** Content shown when connecting or a device is selected but node info is not yet available. */
|
||||
@Composable
|
||||
private fun ConnectingDeviceContent(
|
||||
connectionState: ConnectionState,
|
||||
selectedDevice: String,
|
||||
persistedDeviceName: String?,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
|
|
@ -348,7 +350,12 @@ private fun ConnectingDeviceContent(
|
|||
val address = selectedEntry?.address ?: selectedDevice
|
||||
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
|
||||
ConnectingDeviceInfo(
|
||||
connectionState = connectionState,
|
||||
deviceName = name,
|
||||
deviceAddress = address,
|
||||
onClickDisconnect = onClickDisconnect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,18 +34,27 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.disconnect
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
@Composable
|
||||
fun ConnectingDeviceInfo(
|
||||
connectionState: ConnectionState,
|
||||
deviceName: String,
|
||||
deviceAddress: String,
|
||||
onClickDisconnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusText =
|
||||
if (connectionState.isConnected()) {
|
||||
stringResource(Res.string.connected)
|
||||
} else {
|
||||
stringResource(Res.string.connecting)
|
||||
}
|
||||
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -58,7 +67,7 @@ fun ConnectingDeviceInfo(
|
|||
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = stringResource(Res.string.connecting),
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.content.Context
|
|||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
|
|
@ -34,7 +35,11 @@ class RefreshLocalStatsAction :
|
|||
private val nodeManager: NodeManager by inject()
|
||||
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val myNodeNum = nodeManager.myNodeNum ?: return
|
||||
val myNodeNum = nodeManager.myNodeNum.value
|
||||
if (myNodeNum == null) {
|
||||
Logger.w { "RefreshLocalStatsAction: myNodeNum is null, skipping telemetry request" }
|
||||
return
|
||||
}
|
||||
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue