Guide to the FSM patterns used in the Meshtastic SDK engine and how to extend them correctly.
The Meshtastic SDK uses finite state machines (FSMs) in three critical layers:
- HandshakeMachine — the main PhoneAPI handshake (§6 of protocol.md)
- ConnectionState — public projection of engine state to consumers
- TransportState — internal transport-layer state transitions
This document describes the pattern, rationale, and how to avoid duplication when adding new FSMs.
Implemented inline in MeshEngine.kt with the stage enum (HandshakeStage) defined in EngineMessage.kt. Documented in docs/architecture/handshake-fsm.md.
States: Idle → TransportConnecting → Stage1Sending → Stage1Draining → Stage1Settled
↑ ↓
Idle ← Failed InterStageHeartbeat ↓
↓
Stage2Sending ← Stage2Draining ← SeedingSession → Ready
↓ ↑
Failed ←──────────────────────────┘
Each state is a value of the internal enum:
internal enum class HandshakeStage {
Idle,
Stage1Draining,
Stage1Settling,
Stage2Draining,
SeedingSession,
Ready,
}Note: The implementation below is a conceptual illustration of the FSM pattern. In practice, the handshake logic is implemented directly in
MeshEngine'swhen-matching overEngineMessagevariants rather than as a standalone puretransition()function. The pattern principles (explicit side effects, exhaustive event handling) still apply, but the code structure is the single-writer actor loop, not a separate state machine class.
Transitions are conceptually a state machine:
fun transition(
state: HandshakeState,
event: HandshakeEvent
): Pair<HandshakeState, List<SideEffect>> {
return when (state) {
is HandshakeState.Idle -> {
when (event) {
is HandshakeEvent.Connect -> {
TransportConnecting(attempt = 1) to listOf(
SideEffect.ConnectTransport
)
}
else -> state to emptyList()
}
}
is HandshakeState.TransportConnecting -> {
when (event) {
is HandshakeEvent.TransportConnected -> {
Stage1Sending to listOf(
SideEffect.SendConfigRequest(SPECIAL_NONCE_ONLY_CONFIG)
)
}
is HandshakeEvent.TransportFailed -> {
Failed(event.cause) to listOf()
}
else -> state to emptyList()
}
}
// ... other states
}
}- Pure function —
transition()is deterministic and has no side effects. Given the same(state, event)pair, it always returns the same result. - Explicit side effects — Side effects (sending frames, starting timers, emitting state to flows) are collected in a
List<SideEffect>and executed by the engine actor after state update. - Exhaustive event handling — The
whenexpression over events is exhaustive within the state. Unhandled events silently stay in the same state (no-op). - Timeout as an event — Timeouts are modeled as events (
HandshakeEvent.StageTimeout), not as implicit delays. This makes testing deterministic.
The handshake FSM is internal (HandshakeState). The engine projects it to a public ConnectionState for consumer observability:
public sealed interface ConnectionState {
object Disconnected : ConnectionState
data class Connecting(val attempt: Int) : ConnectionState
data class Configuring(val phase: ConfigPhase, val progress: Float) : ConnectionState
object Connected : ConnectionState
data class Reconnecting(val cause: Throwable, val attempt: Int) : ConnectionState
}Projection is a deterministic function:
fun HandshakeState.toConnectionState(): ConnectionState = when (this) {
is Idle -> Disconnected
is TransportConnecting -> Connecting(attempt = attempt)
is Stage1Sending -> Configuring("STAGE_1_REQUESTED", 0f)
// ... etc
}The benefits:
- Abstraction — consumers never see
HandshakeState's internal complexity. - Stability — adding a new internal
HandshakeStatedoesn't break consumer code if the publicConnectionStatedoesn't change. - Versioning —
ConnectionStateis part of the public API and protected bycheckKotlinAbi.
Transport layers (BLE, Serial, TCP) each implement a simple state machine:
sealed interface TransportState {
object Idle : TransportState
object Connecting : TransportState
object Connected : TransportState
data class Error(val reason: String, val recoverable: Boolean) : TransportState
}This is simpler than the handshake FSM because:
- Transport only cares about connection (yes/no) and errors.
- Handshake complexity (stages, config) is handled by
HandshakeMachine, not the transport.
Use parameterized (table-driven) tests to verify every state + event combination:
@Test
fun testHandshakeTransitions() {
val testCases = listOf(
// (state, event) -> expectedNextState, expectedSideEffects
Idle to Connect(1)
-> TransportConnecting(1) with listOf(SideEffect.ConnectTransport),
TransportConnecting(1) to TransportConnected
-> Stage1Sending with listOf(SideEffect.SendConfigRequest(69420)),
TransportConnecting(1) to TransportFailed("GATT error")
-> Failed("GATT error") with listOf(),
Stage1Draining to ConfigComplete(69420)
-> Stage1Settled with listOf(SideEffect.StartSettleTimer(100.ms)),
// ... dozens more cases covering all paths
)
testCases.forEach { (state, event, expectedNext, expectedEffects) ->
val (nextState, effects) = transition(state, event)
assertEquals(expectedNext, nextState)
assertEquals(expectedEffects, effects)
}
}This pattern ensures:
- Completeness — every documented state transition has a test.
- Regressions — adding a new transition inadvertently cannot silently change existing ones.
- Clarity — the table is a specification; readers see all transitions at a glance.
See core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt for the canonical implementation.
If you need to add a new FSM (e.g., a "SeedingSessionFSM" or "AdminRpcFSM"):
sealed class MyFsmState {
object Idle : MyFsmState()
object Working : MyFsmState()
data class Failed(val cause: Throwable) : MyFsmState()
}
sealed class MyFsmEvent {
object Start : MyFsmEvent()
data class Complete(val result: String) : MyFsmEvent()
data class Error(val cause: Throwable) : MyFsmEvent()
}sealed class MyFsmEffect {
object DoWork : MyFsmEffect()
data class EmitResult(val value: String) : MyFsmEffect()
}fun transition(state: MyFsmState, event: MyFsmEvent): Pair<MyFsmState, List<MyFsmEffect>> {
return when (state) {
is MyFsmState.Idle -> when (event) {
is MyFsmEvent.Start -> MyFsmState.Working to listOf(MyFsmEffect.DoWork)
else -> state to emptyList()
}
is MyFsmState.Working -> when (event) {
is MyFsmEvent.Complete -> MyFsmState.Idle to listOf(MyFsmEffect.EmitResult(event.result))
is MyFsmEvent.Error -> MyFsmState.Failed(event.cause) to emptyList()
else -> state to emptyList()
}
// ... etc
}
}@Test
fun testMyFsmTransitions() {
val testCases = listOf(
// state, event -> expectedState, expectedEffects
Idle to Start -> Working to listOf(DoWork),
Working to Complete("ok") -> Idle to listOf(EmitResult("ok")),
Working to Error(cause) -> Failed(cause) to emptyList(),
)
testCases.forEach { ... }
}Create a diagram (Mermaid or ASCII) showing the state graph and document each transition's rationale.
Have the engine actor call transition() and apply side effects:
private suspend fun handleFsmEvent(event: MyFsmEvent) {
val (nextState, effects) = transition(fsmState, event)
fsmState = nextState
effects.forEach { effect ->
when (effect) {
is MyFsmEffect.DoWork -> doWork()
is MyFsmEffect.EmitResult -> emit(event = MyFsmEvent.Result(effect.value))
}
}
}❌ Don't do this:
if (handshakeState is Stage1Draining) {
when (envelope) {
is FromRadio.Config -> { /* ... */ }
is FromRadio.ConfigComplete -> { /* transition to Stage1Settled */ }
}
}
if (handshakeState is Stage2Draining) {
when (envelope) {
is FromRadio.NodeInfo -> { /* ... */ }
is FromRadio.ConfigComplete -> { /* transition to SeedingSession */ }
}
}This scatters state logic across handlers and is error-prone.
✅ Do this:
private suspend fun handleEnvelope(envelope: FromRadio) {
val event = when (envelope) {
is FromRadio.Config -> HandshakeEvent.ConfigReceived(envelope.config)
is FromRadio.ConfigComplete -> HandshakeEvent.ConfigComplete(envelope.config_complete_id)
// ... etc
}
val (nextState, effects) = transition(handshakeState, event)
// Apply state and effects
}The transition logic is centralized in one transition() function.
❌ Don't do this:
var inStage1 = false
var inStage2 = false
var inSeedingSession = false
// Scattered throughout code:
if (inStage1) { /* ... */ }
if (inStage2) { /* ... */ }
// Hard to reason about; easy to violate invariants (e.g., both flags true)✅ Do this:
sealed class HandshakeState {
object Stage1Sending : HandshakeState()
object Stage2Sending : HandshakeState()
// ...
}
// Single source of truth; impossible to be in two states at onceTo detect state machine anomalies, log every transition:
fun transition(state: HandshakeState, event: HandshakeEvent): Pair<HandshakeState, List<SideEffect>> {
val nextState = /* ... */
logger.debug("HandshakeFSM: $state + $event -> $nextState")
return nextState to effects
}In production, this helps diagnose unexpected reconnects or hangs.
docs/architecture/handshake-fsm.md— Handshake FSM specificationdocs/architecture/engine-actor.md— Engine actor design and how it uses FSMsdocs/testing.md— Testing patterns- ADR-002: Single-writer actor concurrency — Why we use FSMs instead of locks