diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index da7a9e2a0..0af7adbd1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -82,7 +82,7 @@ class FromRadioPacketHandlerImpl( serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})") } nodeInfoBatch != null -> { - nodeInfoBatch.items.forEach { info -> router.value.configFlowManager.handleNodeInfo(info) } + router.value.configFlowManager.handleNodeInfoBatch(nodeInfoBatch.items) serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})") } configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index 6a8bd5e40..ffdc5cdf0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -77,8 +77,8 @@ class MeshConfigFlowManagerImpl( override fun handleConfigComplete(configCompleteId: Int) { when (configCompleteId) { HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete() - HandshakeConstants.NODE_INFO_NONCE, HandshakeConstants.BATCH_NODE_INFO_NONCE, + HandshakeConstants.NODE_INFO_NONCE, -> handleNodeInfoComplete() else -> Logger.w { "Config complete id mismatch: $configCompleteId" } } @@ -171,6 +171,10 @@ class MeshConfigFlowManagerImpl( newNodes.add(info) } + override fun handleNodeInfoBatch(items: List) { + newNodes.addAll(items) + } + override fun handleFileInfo(info: FileInfo) { Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" } scope.handledLaunch { radioConfigRepository.addFileInfo(info) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index c4fc861b3..1e8bfef25 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -163,20 +163,19 @@ class FromRadioPacketHandlerImplTest { } @Test - fun `handleFromRadio routes NODE_INFO_BATCH items to configFlowManager and updates status`() { + 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 batch = NodeInfoBatch(items = listOf(node1, node2, node3)) + 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.handleNodeInfo(node1) } - verify { configFlowManager.handleNodeInfo(node2) } - verify { configFlowManager.handleNodeInfo(node3) } + verify { configFlowManager.handleNodeInfoBatch(items) } verify { serviceRepository.setConnectionProgress("Nodes (3)") } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt new file mode 100644 index 000000000..0e299fe4a --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -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 . + */ +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(MockMode.autofill) + private val nodeRepository = FakeNodeRepository() + private val serviceRepository = FakeServiceRepository() + private val radioConfigRepository = mock(MockMode.autofill) + private val serviceBroadcasts = mock(MockMode.autofill) + private val analytics = mock(MockMode.autofill) + private val commandSender = mock(MockMode.autofill) + private val packetHandler = mock(MockMode.autofill) + private val nodeManager = mock(MockMode.autofill) + + // Tracks nodes installed via nodeManager.installNodeInfo so assertions can inspect them + private val installedNodes: MutableMap = 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()) } returns Unit + every { serviceBroadcasts.broadcastConnection() } returns Unit + every { nodeManager.nodeDBbyNodeNum } returns installedNodes + every { nodeManager.myNodeNum } returns null + every { nodeManager.setNodeDbReady(any()) } returns Unit + every { nodeManager.setAllowNodeDbWrites(any()) } returns Unit + every { nodeManager.installNodeInfo(any(), any()) } 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() } + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 9b0b50490..0a840b3d3 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -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 @@ -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 @@ -151,17 +153,6 @@ class MeshConnectionManagerImplTest { @Test fun `Disconnected state stops services`() = runTest(testDispatcher) { - every { packetHandler.sendToRadio(any()) } 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()) } 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 @@ -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() + every { packetHandler.sendToRadio(any()) } 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", + ) + } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt index 7e9c7e991..716fa8860 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt @@ -352,8 +352,9 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str } /** - * Stage 2: send all nodes as a single [NodeInfoBatch], then config_complete_id. After the handshake completes, - * simulate live traffic. + * Stage 2: send all nodes as a single [NodeInfoBatch], then config_complete_id. Live traffic is simulated in a + * separate coroutine after a short delay so it arrives *after* the handshake completion coroutine has had a chance + * to commit the node DB — matching real-world ordering. */ private fun sendStage2NodeInfoResponse(configId: Int) { val batch = @@ -364,18 +365,22 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str makeSimNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson ), ) - val packets = - arrayOf( - FromRadio(node_info_batch = batch), - FromRadio(config_complete_id = configId), + listOf(FromRadio(node_info_batch = batch), FromRadio(config_complete_id = configId)).forEach { p -> + service.handleFromRadio(p.encode()) + } - // Simulate live traffic after handshake + // Simulate live traffic after the handshake has completed. Launched in a separate + // coroutine with a small delay so these packets arrive after onNodeDbReady() runs. + service.serviceScope.handledLaunch { + delay(200) + listOf( makeTextMessage(MY_NODE + 1), makeNeighborInfo(MY_NODE + 1), makePosition(MY_NODE + 1), makeTelemetry(MY_NODE + 1), makeNodeStatus(MY_NODE + 1), ) - packets.forEach { p -> service.handleFromRadio(p.encode()) } + .forEach { p -> service.handleFromRadio(p.encode()) } + } } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt index 06c68caf2..6e3e420a3 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt @@ -18,9 +18,9 @@ package org.meshtastic.core.repository /** * Shared constants for the two-stage mesh handshake protocol. - * - * Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`BATCH_NODE_INFO_NONCE`): - * requests the full node database with batched NodeInfo delivery. + * - Stage 1 ([CONFIG_NONCE]): requests device config, module config, and channels. + * - Stage 2 ([BATCH_NODE_INFO_NONCE], primary): requests the full node database with batched [NodeInfoBatch] delivery. + * - Stage 2 ([NODE_INFO_NONCE], legacy): requests node info one-at-a-time; kept for firmware that pre-dates batching. * * Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these. */ @@ -31,6 +31,8 @@ object HandshakeConstants { /** Nonce sent in `want_config_id` to request node info only — unbatched legacy (Stage 2). */ const val NODE_INFO_NONCE = 69421 - /** Nonce sent in `want_config_id` to request node info only — batched (Stage 2). */ + // 69422 intentionally skipped — reserved for future use. + + /** Nonce sent in `want_config_id` to request node info only — batched (Stage 2, primary). */ const val BATCH_NODE_INFO_NONCE = 69423 } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt index 2a92f8909..8161b66d7 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -36,6 +36,16 @@ interface MeshConfigFlowManager { /** Handles received node information. */ fun handleNodeInfo(info: NodeInfo) + /** + * Handles a batch of node information records delivered in a single [NodeInfoBatch] message. + * + * The default implementation simply delegates to [handleNodeInfo] for each item. Implementations should override + * this with a bulk `addAll` to avoid per-item overhead on large meshes. + */ + fun handleNodeInfoBatch(items: List) { + items.forEach { handleNodeInfo(it) } + } + /** * Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST. *