Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class FromRadioPacketHandlerImpl(
val myInfo = proto.my_info
val metadata = proto.metadata
val nodeInfo = proto.node_info
val nodeInfoBatch = proto.node_info_batch
val configCompleteId = proto.config_complete_id
val mqttProxyMessage = proto.mqttClientProxyMessage
val queueStatus = proto.queueStatus
Expand All @@ -80,6 +81,10 @@ class FromRadioPacketHandlerImpl(
router.value.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
}
nodeInfoBatch != null -> {
router.value.configFlowManager.handleNodeInfoBatch(nodeInfoBatch.items)
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
}
configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ class MeshConfigFlowManagerImpl(
override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
HandshakeConstants.BATCH_NODE_INFO_NONCE,
HandshakeConstants.NODE_INFO_NONCE,
-> handleNodeInfoComplete()
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}
Expand Down Expand Up @@ -120,10 +122,11 @@ class MeshConfigFlowManagerImpl(

private fun handleNodeInfoComplete() {
Logger.i { "NodeInfo complete (Stage 2)" }
val entities = newNodes.map { info ->
nodeManager.installNodeInfo(info, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[info.num]!!
}
val entities =
newNodes.map { info ->
nodeManager.installNodeInfo(info, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[info.num]!!
}
newNodes.clear()

scope.handledLaunch {
Expand Down Expand Up @@ -168,6 +171,10 @@ class MeshConfigFlowManagerImpl(
newNodes.add(info)
}

override fun handleNodeInfoBatch(items: List<NodeInfo>) {
newNodes.addAll(items)
}

override fun handleFileInfo(info: FileInfo) {
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ class MeshConnectionManagerImpl(
}

override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.BATCH_NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
action()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfoBatch
import org.meshtastic.proto.QueueStatus
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand Down Expand Up @@ -161,6 +162,23 @@ class FromRadioPacketHandlerImplTest {
verify { mqttManager.handleMqttProxyMessage(proxyMsg) }
}

@Test
fun `handleFromRadio routes NODE_INFO_BATCH to handleNodeInfoBatch and updates status`() {
val node1 = ProtoNodeInfo(num = 1111)
val node2 = ProtoNodeInfo(num = 2222)
val node3 = ProtoNodeInfo(num = 3333)
val items = listOf(node1, node2, node3)
val batch = NodeInfoBatch(items = items)
val proto = FromRadio(node_info_batch = batch)

every { configFlowManager.newNodeCount } returns 3

handler.handleFromRadio(proto)

verify { configFlowManager.handleNodeInfoBatch(items) }
verify { serviceRepository.setConnectionProgress("Nodes (3)") }
}

@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
val notification = ClientNotification(message = "test")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* 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.verifyNoMoreCalls
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
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.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
class MeshConfigFlowManagerImplTest {

private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val serviceRepository = FakeServiceRepository()
private val radioConfigRepository = mock<RadioConfigRepository>(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 nodeManager = mock<NodeManager>(MockMode.autofill)

// Tracks nodes installed via nodeManager.installNodeInfo so assertions can inspect them
private val installedNodes: MutableMap<Int, Node> = mutableMapOf()

private val testDispatcher = UnconfinedTestDispatcher()

private lateinit var manager: MeshConfigFlowManagerImpl

@BeforeTest
fun setUp() {
every { connectionManager.startNodeInfoOnly() } returns Unit
every { connectionManager.onRadioConfigLoaded() } returns Unit
every { connectionManager.onNodeDbReady() } returns Unit
every { packetHandler.sendToRadio(any<ToRadio>()) } returns Unit
every { serviceBroadcasts.broadcastConnection() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns installedNodes
every { nodeManager.myNodeNum } returns null
every { nodeManager.setNodeDbReady(any<Boolean>()) } returns Unit
every { nodeManager.setAllowNodeDbWrites(any<Boolean>()) } returns Unit
every { nodeManager.installNodeInfo(any<NodeInfo>(), any<Boolean>()) } returns Unit

manager =
MeshConfigFlowManagerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
packetHandler = packetHandler,
)
}

// -------------------------------------------------------------------------
// handleNodeInfo / handleNodeInfoBatch — accumulation
// -------------------------------------------------------------------------

@Test
fun `handleNodeInfo accumulates nodes and increments newNodeCount`() {
manager.handleNodeInfo(NodeInfo(num = 1))
manager.handleNodeInfo(NodeInfo(num = 2))

assertEquals(2, manager.newNodeCount)
}

@Test
fun `handleNodeInfoBatch adds all items in one shot`() {
val items = listOf(NodeInfo(num = 10), NodeInfo(num = 11), NodeInfo(num = 12))

manager.handleNodeInfoBatch(items)

assertEquals(3, manager.newNodeCount)
}

@Test
fun `handleNodeInfoBatch with empty list leaves count at zero`() {
manager.handleNodeInfoBatch(emptyList())

assertEquals(0, manager.newNodeCount)
}

@Test
fun `handleNodeInfoBatch with single item equals count of one`() {
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 7)))

assertEquals(1, manager.newNodeCount)
}

@Test
fun `handleNodeInfoBatch and handleNodeInfo accumulate together`() {
manager.handleNodeInfo(NodeInfo(num = 1))
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 2), NodeInfo(num = 3)))

assertEquals(3, manager.newNodeCount)
}

// -------------------------------------------------------------------------
// handleConfigComplete — nonce routing
// -------------------------------------------------------------------------

@Test
fun `handleConfigComplete with BATCH_NODE_INFO_NONCE triggers Stage 2 completion`() = runTest(testDispatcher) {
manager.start(backgroundScope)
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 100)))
// installNodeInfo is a no-op mock; seed the map so nodeDBbyNodeNum[num] is non-null
installedNodes[100] = Node(num = 100)

manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
advanceUntilIdle()

verify { connectionManager.onNodeDbReady() }
}

@Test
fun `handleConfigComplete with NODE_INFO_NONCE (legacy) also triggers Stage 2 completion`() =
runTest(testDispatcher) {
manager.start(backgroundScope)
manager.handleNodeInfo(NodeInfo(num = 200))
installedNodes[200] = Node(num = 200)

manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()

verify { connectionManager.onNodeDbReady() }
}

@Test
fun `handleConfigComplete with unknown nonce takes no action`() = runTest(testDispatcher) {
manager.start(backgroundScope)

manager.handleConfigComplete(99999)
advanceUntilIdle()

verifyNoMoreCalls(connectionManager)
}

// -------------------------------------------------------------------------
// handleConfigComplete Stage 2 — newNodes cleared after completion
// -------------------------------------------------------------------------

@Test
fun `newNodeCount resets to zero after Stage 2 completion`() = runTest(testDispatcher) {
manager.start(backgroundScope)
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 1), NodeInfo(num = 2)))
installedNodes[1] = Node(num = 1)
installedNodes[2] = Node(num = 2)
assertEquals(2, manager.newNodeCount)

manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
advanceUntilIdle()

assertEquals(0, manager.newNodeCount)
}

// -------------------------------------------------------------------------
// handleConfigComplete Stage 2 — empty batch edge case
// -------------------------------------------------------------------------

@Test
fun `Stage 2 completion with empty batch signals readiness with no installed nodes`() = runTest(testDispatcher) {
manager.start(backgroundScope)
// Intentionally skip any handleNodeInfo* calls — simulates an empty NodeInfoBatch

manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
advanceUntilIdle()

verify { connectionManager.onNodeDbReady() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
Expand All @@ -54,6 +55,7 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.ToRadio
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand Down Expand Up @@ -151,17 +153,6 @@ class MeshConnectionManagerImplTest {

@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager.start(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
Expand Down Expand Up @@ -267,4 +258,25 @@ class MeshConnectionManagerImplTest {
verify { mqttManager.start(any(), true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}

@Test
fun `startNodeInfoOnly sends BATCH_NODE_INFO_NONCE not the legacy nonce`() = runTest(testDispatcher) {
val sentPackets = mutableListOf<ToRadio>()
every { packetHandler.sendToRadio(any<ToRadio>()) } calls
{ call ->
sentPackets.add(call.arg(0))
Unit
}

manager.start(backgroundScope)
manager.startNodeInfoOnly()
advanceUntilIdle()

val nodeInfoPacket = sentPackets.firstOrNull { (it.want_config_id ?: 0) != 0 }
assertEquals(
HandshakeConstants.BATCH_NODE_INFO_NONCE,
nodeInfoPacket?.want_config_id,
"startNodeInfoOnly must use BATCH_NODE_INFO_NONCE, not the legacy NODE_INFO_NONCE",
)
}
}
Loading
Loading