Skip to content

Commit a74d5d4

Browse files
committed
feat(core): implement batched node info handling for mesh handshake
This commit optimizes the mesh handshake protocol by introducing explicit support for `NodeInfoBatch` messages. It updates the configuration flow to handle both primary batched node delivery and legacy single-node delivery for backwards compatibility with older firmware. Key changes include: - **Batch Processing Optimization:** - Added `handleNodeInfoBatch` to the `MeshConfigFlowManager` interface to allow bulk processing of node information, reducing per-item overhead during the initial handshake. - Updated `MeshConfigFlowManagerImpl` to accumulate batched nodes efficiently using `addAll`. - Refactored `FromRadioPacketHandlerImpl` to delegate batch processing directly to the manager instead of iterating through individual items. - **Handshake Protocol Updates:** - Updated `HandshakeConstants` to distinguish between `BATCH_NODE_INFO_NONCE` (primary Stage 2) and `NODE_INFO_NONCE` (legacy Stage 2). - Modified `handleConfigComplete` logic to trigger Stage 2 completion for both batched and legacy nonces. - Ensured `MeshConnectionManager` prioritizes the batch nonce when requesting node information. - **Testing & Simulation:** - Created `MeshConfigFlowManagerImplTest` to validate node accumulation, batch handling, and handshake nonce routing. - Improved `MockInterface` to better simulate real-world packet ordering by delaying live traffic until after the handshake completion coroutine has processed the node database. - Added verification tests to ensure the connection manager uses the correct batching nonces. Specific changes: - Added `handleNodeInfoBatch` implementation to `MeshConfigFlowManagerImpl`. - Updated documentation in `HandshakeConstants` regarding two-stage mesh handshake protocol. - Refactored `MockInterface.sendStage2NodeInfoResponse` to handle packet encoding and simulation delays. - Added unit tests covering edge cases for empty and mixed node info batches.
1 parent b708b2f commit a74d5d4

8 files changed

Lines changed: 271 additions & 30 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class FromRadioPacketHandlerImpl(
8282
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
8383
}
8484
nodeInfoBatch != null -> {
85-
nodeInfoBatch.items.forEach { info -> router.value.configFlowManager.handleNodeInfo(info) }
85+
router.value.configFlowManager.handleNodeInfoBatch(nodeInfoBatch.items)
8686
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
8787
}
8888
configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ class MeshConfigFlowManagerImpl(
7777
override fun handleConfigComplete(configCompleteId: Int) {
7878
when (configCompleteId) {
7979
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
80-
HandshakeConstants.NODE_INFO_NONCE,
8180
HandshakeConstants.BATCH_NODE_INFO_NONCE,
81+
HandshakeConstants.NODE_INFO_NONCE,
8282
-> handleNodeInfoComplete()
8383
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
8484
}
@@ -171,6 +171,10 @@ class MeshConfigFlowManagerImpl(
171171
newNodes.add(info)
172172
}
173173

174+
override fun handleNodeInfoBatch(items: List<NodeInfo>) {
175+
newNodes.addAll(items)
176+
}
177+
174178
override fun handleFileInfo(info: FileInfo) {
175179
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
176180
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }

core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,19 @@ class FromRadioPacketHandlerImplTest {
163163
}
164164

165165
@Test
166-
fun `handleFromRadio routes NODE_INFO_BATCH items to configFlowManager and updates status`() {
166+
fun `handleFromRadio routes NODE_INFO_BATCH to handleNodeInfoBatch and updates status`() {
167167
val node1 = ProtoNodeInfo(num = 1111)
168168
val node2 = ProtoNodeInfo(num = 2222)
169169
val node3 = ProtoNodeInfo(num = 3333)
170-
val batch = NodeInfoBatch(items = listOf(node1, node2, node3))
170+
val items = listOf(node1, node2, node3)
171+
val batch = NodeInfoBatch(items = items)
171172
val proto = FromRadio(node_info_batch = batch)
172173

173174
every { configFlowManager.newNodeCount } returns 3
174175

175176
handler.handleFromRadio(proto)
176177

177-
verify { configFlowManager.handleNodeInfo(node1) }
178-
verify { configFlowManager.handleNodeInfo(node2) }
179-
verify { configFlowManager.handleNodeInfo(node3) }
178+
verify { configFlowManager.handleNodeInfoBatch(items) }
180179
verify { serviceRepository.setConnectionProgress("Nodes (3)") }
181180
}
182181

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.data.manager
18+
19+
import dev.mokkery.MockMode
20+
import dev.mokkery.answering.returns
21+
import dev.mokkery.every
22+
import dev.mokkery.matcher.any
23+
import dev.mokkery.mock
24+
import dev.mokkery.verify
25+
import dev.mokkery.verifyNoMoreCalls
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
28+
import kotlinx.coroutines.test.advanceUntilIdle
29+
import kotlinx.coroutines.test.runTest
30+
import org.meshtastic.core.model.Node
31+
import org.meshtastic.core.repository.CommandSender
32+
import org.meshtastic.core.repository.HandshakeConstants
33+
import org.meshtastic.core.repository.MeshConnectionManager
34+
import org.meshtastic.core.repository.NodeManager
35+
import org.meshtastic.core.repository.PacketHandler
36+
import org.meshtastic.core.repository.PlatformAnalytics
37+
import org.meshtastic.core.repository.RadioConfigRepository
38+
import org.meshtastic.core.repository.ServiceBroadcasts
39+
import org.meshtastic.core.testing.FakeNodeRepository
40+
import org.meshtastic.core.testing.FakeServiceRepository
41+
import org.meshtastic.proto.NodeInfo
42+
import org.meshtastic.proto.ToRadio
43+
import kotlin.test.BeforeTest
44+
import kotlin.test.Test
45+
import kotlin.test.assertEquals
46+
47+
@OptIn(ExperimentalCoroutinesApi::class)
48+
class MeshConfigFlowManagerImplTest {
49+
50+
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
51+
private val nodeRepository = FakeNodeRepository()
52+
private val serviceRepository = FakeServiceRepository()
53+
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
54+
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
55+
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
56+
private val commandSender = mock<CommandSender>(MockMode.autofill)
57+
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
58+
private val nodeManager = mock<NodeManager>(MockMode.autofill)
59+
60+
// Tracks nodes installed via nodeManager.installNodeInfo so assertions can inspect them
61+
private val installedNodes: MutableMap<Int, Node> = mutableMapOf()
62+
63+
private val testDispatcher = UnconfinedTestDispatcher()
64+
65+
private lateinit var manager: MeshConfigFlowManagerImpl
66+
67+
@BeforeTest
68+
fun setUp() {
69+
every { connectionManager.startNodeInfoOnly() } returns Unit
70+
every { connectionManager.onRadioConfigLoaded() } returns Unit
71+
every { connectionManager.onNodeDbReady() } returns Unit
72+
every { packetHandler.sendToRadio(any<ToRadio>()) } returns Unit
73+
every { serviceBroadcasts.broadcastConnection() } returns Unit
74+
every { nodeManager.nodeDBbyNodeNum } returns installedNodes
75+
every { nodeManager.myNodeNum } returns null
76+
every { nodeManager.setNodeDbReady(any<Boolean>()) } returns Unit
77+
every { nodeManager.setAllowNodeDbWrites(any<Boolean>()) } returns Unit
78+
every { nodeManager.installNodeInfo(any<NodeInfo>(), any<Boolean>()) } returns Unit
79+
80+
manager =
81+
MeshConfigFlowManagerImpl(
82+
nodeManager = nodeManager,
83+
connectionManager = lazy { connectionManager },
84+
nodeRepository = nodeRepository,
85+
radioConfigRepository = radioConfigRepository,
86+
serviceRepository = serviceRepository,
87+
serviceBroadcasts = serviceBroadcasts,
88+
analytics = analytics,
89+
commandSender = commandSender,
90+
packetHandler = packetHandler,
91+
)
92+
}
93+
94+
// -------------------------------------------------------------------------
95+
// handleNodeInfo / handleNodeInfoBatch — accumulation
96+
// -------------------------------------------------------------------------
97+
98+
@Test
99+
fun `handleNodeInfo accumulates nodes and increments newNodeCount`() {
100+
manager.handleNodeInfo(NodeInfo(num = 1))
101+
manager.handleNodeInfo(NodeInfo(num = 2))
102+
103+
assertEquals(2, manager.newNodeCount)
104+
}
105+
106+
@Test
107+
fun `handleNodeInfoBatch adds all items in one shot`() {
108+
val items = listOf(NodeInfo(num = 10), NodeInfo(num = 11), NodeInfo(num = 12))
109+
110+
manager.handleNodeInfoBatch(items)
111+
112+
assertEquals(3, manager.newNodeCount)
113+
}
114+
115+
@Test
116+
fun `handleNodeInfoBatch with empty list leaves count at zero`() {
117+
manager.handleNodeInfoBatch(emptyList())
118+
119+
assertEquals(0, manager.newNodeCount)
120+
}
121+
122+
@Test
123+
fun `handleNodeInfoBatch with single item equals count of one`() {
124+
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 7)))
125+
126+
assertEquals(1, manager.newNodeCount)
127+
}
128+
129+
@Test
130+
fun `handleNodeInfoBatch and handleNodeInfo accumulate together`() {
131+
manager.handleNodeInfo(NodeInfo(num = 1))
132+
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 2), NodeInfo(num = 3)))
133+
134+
assertEquals(3, manager.newNodeCount)
135+
}
136+
137+
// -------------------------------------------------------------------------
138+
// handleConfigComplete — nonce routing
139+
// -------------------------------------------------------------------------
140+
141+
@Test
142+
fun `handleConfigComplete with BATCH_NODE_INFO_NONCE triggers Stage 2 completion`() = runTest(testDispatcher) {
143+
manager.start(backgroundScope)
144+
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 100)))
145+
// installNodeInfo is a no-op mock; seed the map so nodeDBbyNodeNum[num] is non-null
146+
installedNodes[100] = Node(num = 100)
147+
148+
manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
149+
advanceUntilIdle()
150+
151+
verify { connectionManager.onNodeDbReady() }
152+
}
153+
154+
@Test
155+
fun `handleConfigComplete with NODE_INFO_NONCE (legacy) also triggers Stage 2 completion`() =
156+
runTest(testDispatcher) {
157+
manager.start(backgroundScope)
158+
manager.handleNodeInfo(NodeInfo(num = 200))
159+
installedNodes[200] = Node(num = 200)
160+
161+
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
162+
advanceUntilIdle()
163+
164+
verify { connectionManager.onNodeDbReady() }
165+
}
166+
167+
@Test
168+
fun `handleConfigComplete with unknown nonce takes no action`() = runTest(testDispatcher) {
169+
manager.start(backgroundScope)
170+
171+
manager.handleConfigComplete(99999)
172+
advanceUntilIdle()
173+
174+
verifyNoMoreCalls(connectionManager)
175+
}
176+
177+
// -------------------------------------------------------------------------
178+
// handleConfigComplete Stage 2 — newNodes cleared after completion
179+
// -------------------------------------------------------------------------
180+
181+
@Test
182+
fun `newNodeCount resets to zero after Stage 2 completion`() = runTest(testDispatcher) {
183+
manager.start(backgroundScope)
184+
manager.handleNodeInfoBatch(listOf(NodeInfo(num = 1), NodeInfo(num = 2)))
185+
installedNodes[1] = Node(num = 1)
186+
installedNodes[2] = Node(num = 2)
187+
assertEquals(2, manager.newNodeCount)
188+
189+
manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
190+
advanceUntilIdle()
191+
192+
assertEquals(0, manager.newNodeCount)
193+
}
194+
195+
// -------------------------------------------------------------------------
196+
// handleConfigComplete Stage 2 — empty batch edge case
197+
// -------------------------------------------------------------------------
198+
199+
@Test
200+
fun `Stage 2 completion with empty batch signals readiness with no installed nodes`() = runTest(testDispatcher) {
201+
manager.start(backgroundScope)
202+
// Intentionally skip any handleNodeInfo* calls — simulates an empty NodeInfoBatch
203+
204+
manager.handleConfigComplete(HandshakeConstants.BATCH_NODE_INFO_NONCE)
205+
advanceUntilIdle()
206+
207+
verify { connectionManager.onNodeDbReady() }
208+
}
209+
}

core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import org.meshtastic.core.model.DataPacket
3535
import org.meshtastic.core.model.Node
3636
import org.meshtastic.core.repository.AppWidgetUpdater
3737
import org.meshtastic.core.repository.CommandSender
38+
import org.meshtastic.core.repository.HandshakeConstants
3839
import org.meshtastic.core.repository.HistoryManager
3940
import org.meshtastic.core.repository.MeshLocationManager
4041
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -54,6 +55,7 @@ import org.meshtastic.proto.Config
5455
import org.meshtastic.proto.LocalConfig
5556
import org.meshtastic.proto.LocalModuleConfig
5657
import org.meshtastic.proto.ModuleConfig
58+
import org.meshtastic.proto.ToRadio
5759
import kotlin.test.AfterTest
5860
import kotlin.test.BeforeTest
5961
import kotlin.test.Test
@@ -151,17 +153,6 @@ class MeshConnectionManagerImplTest {
151153

152154
@Test
153155
fun `Disconnected state stops services`() = runTest(testDispatcher) {
154-
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
155-
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
156-
every { packetHandler.stopPacketQueue() } returns Unit
157-
every { locationManager.stop() } returns Unit
158-
every { mqttManager.stop() } returns Unit
159-
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
160-
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
161-
every { packetHandler.stopPacketQueue() } returns Unit
162-
every { locationManager.stop() } returns Unit
163-
every { mqttManager.stop() } returns Unit
164-
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
165156
manager.start(backgroundScope)
166157
// Transition to Connected first so that Disconnected actually does something
167158
radioConnectionState.value = ConnectionState.Connected
@@ -267,4 +258,25 @@ class MeshConnectionManagerImplTest {
267258
verify { mqttManager.start(any(), true, true) }
268259
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
269260
}
261+
262+
@Test
263+
fun `startNodeInfoOnly sends BATCH_NODE_INFO_NONCE not the legacy nonce`() = runTest(testDispatcher) {
264+
val sentPackets = mutableListOf<ToRadio>()
265+
every { packetHandler.sendToRadio(any<ToRadio>()) } calls
266+
{ call ->
267+
sentPackets.add(call.arg(0))
268+
Unit
269+
}
270+
271+
manager.start(backgroundScope)
272+
manager.startNodeInfoOnly()
273+
advanceUntilIdle()
274+
275+
val nodeInfoPacket = sentPackets.firstOrNull { (it.want_config_id ?: 0) != 0 }
276+
assertEquals(
277+
HandshakeConstants.BATCH_NODE_INFO_NONCE,
278+
nodeInfoPacket?.want_config_id,
279+
"startNodeInfoOnly must use BATCH_NODE_INFO_NONCE, not the legacy NODE_INFO_NONCE",
280+
)
281+
}
270282
}

core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,9 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
352352
}
353353

354354
/**
355-
* Stage 2: send all nodes as a single [NodeInfoBatch], then config_complete_id. After the handshake completes,
356-
* simulate live traffic.
355+
* Stage 2: send all nodes as a single [NodeInfoBatch], then config_complete_id. Live traffic is simulated in a
356+
* separate coroutine after a short delay so it arrives *after* the handshake completion coroutine has had a chance
357+
* to commit the node DB — matching real-world ordering.
357358
*/
358359
private fun sendStage2NodeInfoResponse(configId: Int) {
359360
val batch =
@@ -364,18 +365,22 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
364365
makeSimNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
365366
),
366367
)
367-
val packets =
368-
arrayOf(
369-
FromRadio(node_info_batch = batch),
370-
FromRadio(config_complete_id = configId),
368+
listOf(FromRadio(node_info_batch = batch), FromRadio(config_complete_id = configId)).forEach { p ->
369+
service.handleFromRadio(p.encode())
370+
}
371371

372-
// Simulate live traffic after handshake
372+
// Simulate live traffic after the handshake has completed. Launched in a separate
373+
// coroutine with a small delay so these packets arrive after onNodeDbReady() runs.
374+
service.serviceScope.handledLaunch {
375+
delay(200)
376+
listOf(
373377
makeTextMessage(MY_NODE + 1),
374378
makeNeighborInfo(MY_NODE + 1),
375379
makePosition(MY_NODE + 1),
376380
makeTelemetry(MY_NODE + 1),
377381
makeNodeStatus(MY_NODE + 1),
378382
)
379-
packets.forEach { p -> service.handleFromRadio(p.encode()) }
383+
.forEach { p -> service.handleFromRadio(p.encode()) }
384+
}
380385
}
381386
}

core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ package org.meshtastic.core.repository
1818

1919
/**
2020
* Shared constants for the two-stage mesh handshake protocol.
21-
*
22-
* Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`BATCH_NODE_INFO_NONCE`):
23-
* requests the full node database with batched NodeInfo delivery.
21+
* - Stage 1 ([CONFIG_NONCE]): requests device config, module config, and channels.
22+
* - Stage 2 ([BATCH_NODE_INFO_NONCE], primary): requests the full node database with batched [NodeInfoBatch] delivery.
23+
* - Stage 2 ([NODE_INFO_NONCE], legacy): requests node info one-at-a-time; kept for firmware that pre-dates batching.
2424
*
2525
* Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these.
2626
*/
@@ -31,6 +31,8 @@ object HandshakeConstants {
3131
/** Nonce sent in `want_config_id` to request node info only — unbatched legacy (Stage 2). */
3232
const val NODE_INFO_NONCE = 69421
3333

34-
/** Nonce sent in `want_config_id` to request node info only — batched (Stage 2). */
34+
// 69422 intentionally skipped — reserved for future use.
35+
36+
/** Nonce sent in `want_config_id` to request node info only — batched (Stage 2, primary). */
3537
const val BATCH_NODE_INFO_NONCE = 69423
3638
}

0 commit comments

Comments
 (0)