fix(connections): send set_time_only at MyNodeInfo instead of onNodeDbReady (#6503)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 authored and GitHub committed 2026-07-28 23:14:19 +00:00
1 parent 39139398e6
commit 8a4e2e3567
8 files changed
+71 -7

No files matched your search

@@ -182,6 +182,12 @@ class CommandSenderImpl(
packetHandler.sendToRadio(packet)
}
override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet = buildAdminPacket(to = destNum, adminMessage = adminMsg)
packetHandler.sendToRadio(ToRadio(packet = packet))
}
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
@@ -343,6 +343,7 @@ class MeshConfigFlowManagerImpl(
// this newer session. The async clear also rechecks transport authority before touching persistence.
clearGeneration = handshakeGeneration.incrementAndGet()
connectionManager.value.onHandshakeProgress()
connectionManager.value.onMyNodeInfoReceived(myInfo.my_node_num)
}
if (!admitted) {
Logger.d { "[DeviceAssociation] discard stale MyNodeInfo gen=${session.generation}" }
@@ -500,20 +500,26 @@ class MeshConnectionManagerImpl(
}
}
override fun onMyNodeInfoReceived(myNodeNum: Int) {
// Set device time as early as possible: MyNodeInfo is the first Stage 1 frame, so this
// lands before the firmware flushes its queued packet backlog and lets it stamp those
// packets with a corrected clock (firmware #11274). A single small write ahead of the
// config/node-info bursts avoids the GATT contention that pushed the old
// onRadioConfigLoaded-time send out of Stage 1. Must bypass the outbound packet queue:
// it only drains once Connected, which would hold this until after the backlog flush.
commandSender.sendAdminImmediate(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
override suspend fun onNodeDbReady() {
// Collapse cancel+clear into one atomic swap so a concurrent re-arm cannot
// orphan a job in the gap between cancel and reassign.
handshakeTimeout.getAndSet(null)?.cancel()
val myNodeNum = nodeManager.myNodeNum.value ?: 0
// Set device time now that the full node picture is ready. Sending this during Stage 1
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
// Proactively seed the session passkey. The firmware embeds session_passkey in every
// admin *response* (wantResponse=true), but set_time_only has no response. A get_owner
// request is the lightest way to trigger a response and populate the passkey cache so
// that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
// admin *response* (wantResponse=true), but set_time_only (sent at MyNodeInfo) has no
// response. A get_owner request is the lightest way to trigger a response and populate the
// passkey cache so that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) }
// Start MQTT if enabled
@@ -21,6 +21,7 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.matcher.matches
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
@@ -40,11 +41,13 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -202,6 +205,34 @@ class CommandSenderImplTest {
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
}
// --- sendAdminImmediate ---
@Test
fun sendAdminImmediate_dispatchesDirectToRadioWithPasskeyAndNoResponse() {
val passkey = "secret".encodeUtf8()
every { sessionManager.getPasskey(DEST_NODE) } returns passkey
every { packetHandler.sendToRadio(any<ToRadio>()) } returns Unit
commandSender.sendAdminImmediate(DEST_NODE) { AdminMessage(set_time_only = 12345) }
// Direct ToRadio dispatch (not the Connected-gated MeshPacket queue), correct destination,
// no want_response, and the session passkey injected into the admin payload.
verify {
packetHandler.sendToRadio(
matches<ToRadio> { toRadio ->
val packet = toRadio.packet ?: return@matches false
val decoded = packet.decoded ?: return@matches false
val admin = AdminMessage.ADAPTER.decode(decoded.payload)
packet.to == DEST_NODE &&
decoded.portnum == PortNum.ADMIN_APP &&
!decoded.want_response &&
admin.set_time_only == 12345 &&
admin.session_passkey == passkey
},
)
}
}
// --- requestTraceroute ---
@Test
@@ -114,6 +114,8 @@ class LockdownCoordinatorImplTest {
initFn: () -> AdminMessage,
) = Unit
override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) = Unit
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
@@ -149,6 +151,8 @@ class LockdownCoordinatorImplTest {
override fun startNodeInfoOnly() = Unit
override fun onMyNodeInfoReceived(myNodeNum: Int) = Unit
override suspend fun onNodeDbReady() = Unit
override fun updateTelemetry(t: Telemetry) = Unit
@@ -48,6 +48,13 @@ interface CommandSender {
initFn: () -> AdminMessage,
)
/**
* Sends an admin message immediately, bypassing the outbound packet queue. The queue only drains while the
* connection state is Connected, so mid-handshake sends (e.g. set_time_only at MyNodeInfo) must use this path or
* they stall until the handshake finishes.
*/
fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage)
/**
* Sends an admin message and suspends until the radio acknowledges it.
*
@@ -29,6 +29,13 @@ interface MeshConnectionManager {
/** Initiates the node information synchronization stage. */
fun startNodeInfoOnly()
/**
* Called when the MyNodeInfo frame arrives — the first frame of the Stage 1 config stream. This is the earliest
* point where the local node number is authoritative, before the firmware flushes its queued packet backlog, so
* time-sensitive setup (e.g. set_time_only) sent here lets the firmware stamp backlog packets with real rx_time.
*/
fun onMyNodeInfoReceived(myNodeNum: Int)
/** Called when the node database is ready and fully populated. */
suspend fun onNodeDbReady()
@@ -134,6 +134,8 @@ class TAKMeshIntegrationTest {
initFn: () -> AdminMessage,
) {}
override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {}
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,