diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index b245a57db08..bc307da839d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -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, 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 7640e2285a1..d3453615070 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 @@ -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}" } 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 59e426e5efc..18517624093 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 @@ -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 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt index f19259e0f44..15e7d806877 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt @@ -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()) } } + // --- sendAdminImmediate --- + + @Test + fun sendAdminImmediate_dispatchesDirectToRadioWithPasskeyAndNoResponse() { + val passkey = "secret".encodeUtf8() + every { sessionManager.getPasskey(DEST_NODE) } returns passkey + every { packetHandler.sendToRadio(any()) } 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 -> + 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 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt index a0f488a3bb2..9c4744239ab 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt @@ -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 diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index 5d372e1507b..3ffcd54c5e3 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -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. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index 386db155c0c..4df1d797965 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -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() diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index a8d590b1973..e087dc2f129 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -134,6 +134,8 @@ class TAKMeshIntegrationTest { initFn: () -> AdminMessage, ) {} + override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {} + override suspend fun sendAdminAwait( destNum: Int, requestId: Int,