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 9a84026fa7..0af7adbd15 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 @@ -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 @@ -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) 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 2e880bb3b1..ffdc5cdf03 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,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" } } } @@ -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 { @@ -168,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/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index bd0cafa4c4..cce43898fa 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -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() } 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 d3f0efc32f..1e8bfef255 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 @@ -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 @@ -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") 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 0000000000..0e299fe4ad --- /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 9b0b50490c..0a840b3d34 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 4990ee7abd..716fa8860f 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 @@ -25,6 +25,7 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.getInitials +import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.proto.AdminMessage @@ -39,6 +40,7 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.Neighbor import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.NodeInfoBatch import org.meshtastic.proto.PortNum import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.Routing @@ -301,58 +303,84 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) } - private fun sendConfigResponse(configId: Int) { - Logger.d { "Sending mock config response" } + // / Generate a fake NodeInfo for a simulated node + @Suppress("MagicNumber") + private fun makeSimNodeInfo(numIn: Int, lat: Double, lon: Double) = NodeInfo( + num = numIn, + user = + User( + id = DataPacket.nodeNumToDefaultId(numIn), + long_name = "Sim " + numIn.toString(16), + short_name = getInitials("Sim " + numIn.toString(16)), + hw_model = HardwareModel.ANDROID_SIM, + ), + position = + ProtoPosition( + latitude_i = org.meshtastic.core.model.Position.degI(lat), + longitude_i = org.meshtastic.core.model.Position.degI(lon), + altitude = 35, + time = nowSeconds.toInt(), + precision_bits = Random.nextInt(10, 19), + ), + ) - // / Generate a fake node info entry - @Suppress("MagicNumber") - fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio( - node_info = - NodeInfo( - num = numIn, - user = - User( - id = DataPacket.nodeNumToDefaultId(numIn), - long_name = "Sim " + numIn.toString(16), - short_name = getInitials("Sim " + numIn.toString(16)), - hw_model = HardwareModel.ANDROID_SIM, - ), - position = - ProtoPosition( - latitude_i = org.meshtastic.core.model.Position.degI(lat), - longitude_i = org.meshtastic.core.model.Position.degI(lon), - altitude = 35, - time = nowSeconds.toInt(), - precision_bits = Random.nextInt(10, 19), - ), - ), - ) + private fun sendConfigResponse(configId: Int) { + Logger.d { "Sending mock config response for nonce=$configId" } + when (configId) { + HandshakeConstants.CONFIG_NONCE -> sendStage1ConfigResponse(configId) + HandshakeConstants.BATCH_NODE_INFO_NONCE, + HandshakeConstants.NODE_INFO_NONCE, + -> sendStage2NodeInfoResponse(configId) + else -> Logger.w { "Unknown config nonce $configId — ignoring" } + } + } - // Simulated network data to feed to our app + /** Stage 1: send my_info, metadata, config, channels, then config_complete_id. No nodes. */ + private fun sendStage1ConfigResponse(configId: Int) { val packets = arrayOf( - // MyNodeInfo FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)), FromRadio( metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM), ), - - // Fake NodeDB - makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas - makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson FromRadio(config = Config(lora = defaultLoRaConfig)), FromRadio(config = Config(lora = defaultLoRaConfig)), FromRadio(channel = defaultChannel), FromRadio(config_complete_id = configId), + ) + packets.forEach { p -> service.handleFromRadio(p.encode()) } + } - // Done with config response, now pretend to receive some text messages + /** + * 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 = + NodeInfoBatch( + items = + listOf( + makeSimNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas + makeSimNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson + ), + ) + listOf(FromRadio(node_info_batch = batch), FromRadio(config_complete_id = configId)).forEach { p -> + service.handleFromRadio(p.encode()) + } + + // 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 7b403aa366..6e3e420a35 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 (`NODE_INFO_NONCE`): requests - * the full node database. + * - 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. */ @@ -28,6 +28,11 @@ object HandshakeConstants { /** Nonce sent in `want_config_id` to request config-only (Stage 1). */ const val CONFIG_NONCE = 69420 - /** Nonce sent in `want_config_id` to request node info only (Stage 2). */ + /** Nonce sent in `want_config_id` to request node info only — unbatched legacy (Stage 2). */ const val NODE_INFO_NONCE = 69421 + + // 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 2a92f8909a..8161b66d79 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. *