Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ class TAKMeshIntegrationTest {
initFn: () -> AdminMessage,
) {}

override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {}

override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
Expand Down