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
19 changes: 19 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ package org.meshtastic.core.data.manager

import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.CommandSender
Expand Down Expand Up @@ -77,10 +79,14 @@ class MeshConfigFlowManagerImpl(
* Stage 1: receiving device config, module config, channels, and metadata.
*
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
* together by [buildMyNodeInfo] at Stage 1 completion.
* together by [buildMyNodeInfo] at Stage 1 completion. Some firmware/network paths can deliver NodeInfo before
* the Stage 2 request; keep those packets so the later node-list phase can still make progress.
*/
data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) :
HandshakeState()
data class ReceivingConfig(
val rawMyNodeInfo: ProtoMyNodeInfo,
val metadata: DeviceMetadata? = null,
val earlyNodes: List<NodeInfo> = emptyList(),
) : HandshakeState()

/**
* Stage 2: receiving node-info packets from the firmware.
Expand All @@ -95,13 +101,18 @@ class MeshConfigFlowManagerImpl(
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
}

private var handshakeState: HandshakeState = HandshakeState.Idle
private val handshakeState = atomic<HandshakeState>(HandshakeState.Idle)

override val newNodeCount: Int
get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0
get() =
when (val state = handshakeState.value) {
is HandshakeState.ReceivingConfig -> state.earlyNodes.size
is HandshakeState.ReceivingNodeInfo -> state.nodes.size
else -> 0
}

override fun handleConfigComplete(configCompleteId: Int) {
val state = handshakeState
val state = handshakeState.value
when (configCompleteId) {
HandshakeConstants.CONFIG_NONCE -> {
if (state !is HandshakeState.ReceivingConfig) {
Expand Down Expand Up @@ -129,7 +140,7 @@ class MeshConfigFlowManagerImpl(
val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata)
if (finalizedInfo == null) {
Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" }
handshakeState = HandshakeState.Idle
handshakeState.value = HandshakeState.Idle
scope.handledLaunch {
delay(wantConfigDelay)
connectionManager.value.startConfigOnly()
Expand All @@ -149,9 +160,10 @@ class MeshConfigFlowManagerImpl(
}
}

handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
handshakeState.value = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo, nodes = state.earlyNodes)
Logger.i { "myNodeInfo committed" }
connectionManager.value.onRadioConfigLoaded()
serviceStateWriter.setConnectionProgress("Loading node list")

scope.handledLaunch {
delay(wantConfigDelay)
Expand All @@ -160,6 +172,7 @@ class MeshConfigFlowManagerImpl(
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.value.startNodeInfoOnly()
}
connectionManager.value.onHandshakeProgress()
}

private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Expand All @@ -171,7 +184,15 @@ class MeshConfigFlowManagerImpl(
// The async work below (DB writes, broadcasts) proceeds without the guard.
// Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
// Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
handshakeState.value = HandshakeState.Complete(myNodeInfo = info)

// Cancel the transport-aware fast-recovery watchdog SYNCHRONOUSLY, before the async DB
// install work below is launched. The firmware handshake has already completed at this
// point (NODE_INFO_NONCE received); a slow Room commit on a large mesh would otherwise
// falsely trip the 12s fast-recovery timeout before onNodeDbReady() gets a chance to
// cancel it. onNodeDbReady() still performs the same cancel as part of its larger post-
// NodeDB side-effect set, but it runs only after the DB install block finishes.
connectionManager.value.onHandshakeComplete()

val entities =
state.nodes.mapNotNull { nodeInfo ->
Expand All @@ -184,20 +205,39 @@ class MeshConfigFlowManagerImpl(
}

scope.handledLaunch {
nodeRepository.installConfig(info, entities)
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
try {
nodeRepository.installConfig(info, entities)
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Post-handshake NodeDB install failed; restarting transport to recover" }
nodeManager.setNodeDbReady(false)
nodeManager.setAllowNodeDbWrites(false)
connectionManager.value.recoverPostHandshakeFailure()
return@handledLaunch
}

nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceStateWriter.setConnectionState(ConnectionState.Connected)
connectionManager.value.onNodeDbReady()

safeCatching { analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") }
.onFailure { e -> Logger.w(e) { "Failed to set post-handshake analytics attributes" } }
safeCatching { connectionManager.value.onNodeDbReady() }
.onFailure { e -> Logger.e(e) { "Post-connected onNodeDbReady side effects failed" } }
}
// Note: onHandshakeProgress() is intentionally NOT called here. By this point the
// handshake has reached HandshakeState.Complete and the synchronous onHandshakeComplete()
// call above has already cancelled the watchdog. Re-arming via onHandshakeProgress()
// would be both semantically wrong and wasted work. The remaining onHandshakeProgress
// sites cover all genuine progress.
}

override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
Logger.i { "MyNodeInfo received" }

// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
handshakeState.value = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
nodeManager.setMyNodeNum(myInfo.my_node_num)
nodeManager.setFirmwareEdition(myInfo.firmware_edition)
applyEventFirmwareNotificationDefaults(myInfo.firmware_edition)
Expand All @@ -220,35 +260,47 @@ class MeshConfigFlowManagerImpl(
radioConfigRepository.clearDeviceUIConfig()
radioConfigRepository.clearFileManifest()
}
connectionManager.value.onHandshakeProgress()
}

override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
val state = handshakeState
val state = handshakeState.value
if (state is HandshakeState.ReceivingConfig) {
handshakeState = state.copy(metadata = metadata)
handshakeState.value = state.copy(metadata = metadata)
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
// but the DB write does not need to wait until then.
if (metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) }
}
connectionManager.value.onHandshakeProgress()
} else {
Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" }
}
}

override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
val state = handshakeState.value
when (state) {
is HandshakeState.ReceivingConfig -> {
Logger.d { "Buffering NodeInfo received during Stage 1" }
handshakeState.value = state.copy(earlyNodes = state.earlyNodes.withNodeInfo(info))
connectionManager.value.onHandshakeProgress()
}

is HandshakeState.ReceivingNodeInfo -> {
handshakeState.value = state.copy(nodes = state.nodes.withNodeInfo(info))
connectionManager.value.onHandshakeProgress()
}

else -> Logger.w { "Ignoring NodeInfo outside active handshake (state=$state)" }
}
}

override fun handleFileInfo(info: FileInfo) {
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
connectionManager.value.onHandshakeProgress()
Comment thread
jeremiah-k marked this conversation as resolved.
}

override fun triggerWantConfig() {
Expand Down Expand Up @@ -305,3 +357,12 @@ class MeshConfigFlowManagerImpl(
}
}
}

private fun List<NodeInfo>.withNodeInfo(info: NodeInfo): List<NodeInfo> {
val index = indexOfFirst { it.num == info.num }
return if (index >= 0) {
toMutableList().apply { this[index] = info }
} else {
this + info
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceStateWriter
Expand All @@ -41,6 +42,7 @@ class MeshConfigHandlerImpl(
private val radioConfigRepository: RadioConfigRepository,
private val serviceStateWriter: ServiceStateWriter,
private val nodeManager: NodeManager,
private val connectionManager: Lazy<MeshConnectionManager>,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigHandler {

Expand All @@ -59,6 +61,7 @@ class MeshConfigHandlerImpl(
Logger.d { "Device config received: ${config.summarize()}" }
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceStateWriter.setConnectionProgress("Device config received")
connectionManager.value.onHandshakeProgress()
}

override fun handleModuleConfig(config: ModuleConfig) {
Expand All @@ -69,6 +72,7 @@ class MeshConfigHandlerImpl(
config.statusmessage?.let { sm ->
nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
}
connectionManager.value.onHandshakeProgress()
}

override fun handleChannel(channel: Channel) {
Expand All @@ -83,11 +87,16 @@ class MeshConfigHandlerImpl(
} else {
serviceStateWriter.setConnectionProgress("Channels (${index + 1})")
}
connectionManager.value.onHandshakeProgress()
}

override fun handleDeviceUIConfig(config: DeviceUIConfig) {
Logger.d { "DeviceUI config received" }
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
// deviceuiConfig arrives during Stage 1 immediately after my_info. It proves the transport
// is alive, so surface it as handshake progress — without this, a long gap before the next
// meaningful packet could falsely trip the fast-path watchdog on TCP/USB.
connectionManager.value.onHandshakeProgress()
}
}

Expand Down
Loading