From b708b2ff7679b41fa8bfcfa791482a0306fd02e4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 2 Apr 2026 15:35:06 -0500 Subject: [PATCH] feat: implement batched NodeInfo delivery in handshake protocol This commit introduces support for `NodeInfoBatch` messages, allowing the application to receive and process multiple node records efficiently during the second stage of the device handshake. This replaces the legacy approach of sending node information individually, improving synchronization performance. Key changes include: - **Handshake Protocol:** - Added `BATCH_NODE_INFO_NONCE` to `HandshakeConstants`. - Updated `MeshConnectionManagerImpl` to request batched node information by default during the handshake process. - Updated `MeshConfigFlowManagerImpl` to handle the batch-specific completion nonce, ensuring Stage 2 of the handshake finalizes correctly. - **Packet Handling:** - Enhanced `FromRadioPacketHandlerImpl` to detect `node_info_batch` packets. - Implemented logic to iterate through batched items and process each `NodeInfo` record via the `MeshConfigFlowManager`. - Updated connection progress reporting to reflect the count of nodes received within a batch. - **Testing and Simulation:** - Updated `MockInterface` to simulate batched node delivery, refactoring the mock response logic into distinct Stage 1 (config) and Stage 2 (node info) phases. - Added a unit test in `FromRadioPacketHandlerImplTest` to verify that batched items are correctly routed and that the UI connection status is updated. Specific changes: - Modified `HandshakeConstants` to document the transition to batched NodeInfo delivery in Stage 2. - Updated `MeshConnectionManagerImpl.startNodeInfoOnly()` to use the new batch nonce. - Added `nodeInfoBatch` processing branch to the `handleFromRadio` logic. --- .../manager/FromRadioPacketHandlerImpl.kt | 5 ++ .../data/manager/MeshConfigFlowManagerImpl.kt | 13 +-- .../data/manager/MeshConnectionManagerImpl.kt | 2 +- .../manager/FromRadioPacketHandlerImplTest.kt | 19 ++++ .../core/network/radio/MockInterface.kt | 89 ++++++++++++------- .../core/repository/HandshakeConstants.kt | 9 +- 6 files changed, 95 insertions(+), 42 deletions(-) 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 9a84026fa..da7a9e2a0 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 -> { + nodeInfoBatch.items.forEach { info -> router.value.configFlowManager.handleNodeInfo(info) } + 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 2e880bb3b..6a8bd5e40 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.NODE_INFO_NONCE, + HandshakeConstants.BATCH_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 { 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 bd0cafa4c..cce43898f 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 d3f0efc32..c4fc861b3 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,24 @@ class FromRadioPacketHandlerImplTest { verify { mqttManager.handleMqttProxyMessage(proxyMsg) } } + @Test + fun `handleFromRadio routes NODE_INFO_BATCH items to configFlowManager 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 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 { serviceRepository.setConnectionProgress("Nodes (3)") } + } + @Test fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() { val notification = ClientNotification(message = "test") 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 4990ee7ab..7e9c7e991 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,79 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) } + // / 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), + ), + ) + private fun sendConfigResponse(configId: Int) { - Logger.d { "Sending mock config response" } + 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" } + } + } - // / 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), - ), - ), - ) - - // 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. After the handshake completes, + * simulate live traffic. + */ + 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 + ), + ) + val packets = + arrayOf( + FromRadio(node_info_batch = batch), + FromRadio(config_complete_id = configId), + + // Simulate live traffic after handshake 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()) } } } 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 7b403aa36..06c68caf2 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 @@ -19,8 +19,8 @@ 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`): + * requests the full node database with batched NodeInfo delivery. * * Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these. */ @@ -28,6 +28,9 @@ 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 + + /** Nonce sent in `want_config_id` to request node info only — batched (Stage 2). */ + const val BATCH_NODE_INFO_NONCE = 69423 }