Many firmware bugs are actually invalid state transitions. Rust helps by making state explicit and hard to misuse.

I model each controller state as an enum variant with transition functions that consume the old state and return the next one. This prevents accidental mutation paths.

For asynchronous events, I queue typed commands and process them in one control loop. That keeps timing behavior predictable and testable.

The payoff is long-term maintainability: adding a new mode forces compiler-visible updates instead of hidden branching side effects.

A minimal enum state machine

Here is the shape I reach for first. The states are variants, events are a separate enum, and the transition function takes self by value so the old state is consumed and cannot be reused by accident.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
    Idle,
    Heating { target_c: i16 },
    Fault,
}

#[derive(Debug, Clone, Copy)]
enum Event {
    Start { target_c: i16 },
    Reached,
    Overtemp,
    Reset,
}

impl State {
    fn step(self, ev: Event) -> State {
        match (self, ev) {
            (State::Idle, Event::Start { target_c }) => State::Heating { target_c },
            (State::Heating { .. }, Event::Reached) => State::Idle,
            (State::Heating { .. }, Event::Overtemp) => State::Fault,
            (State::Fault, Event::Reset) => State::Idle,
            // Any transition I did not explicitly allow stays put.
            (other, _) => other,
        }
    }
}

Because step moves self, the calling code has to write state = state.step(ev);. There is no way to mutate a stale copy or forget to reassign. The match on the (state, event) tuple also makes the full transition table visible in one place, and adding a new state or event turns any missing arm into something I can grep for and reason about.

In a control loop I drain queued events and fold them through step:

for ev in inbox.drain() {
    state = state.step(ev);
}

Enum vs. compile-time typestate

The enum approach checks transitions at runtime: an invalid (state, event) pair simply falls through to a default arm. That is usually what I want on a device, where events arrive from the outside world and I cannot let a bad message panic the loop.

When I want the compiler to reject an invalid transition, I use the typestate pattern instead: each state is a distinct type, and only the legal transitions exist as methods.

struct Idle;
struct Heating { target_c: i16 }

impl Idle {
    fn start(self, target_c: i16) -> Heating {
        Heating { target_c }
    }
}

impl Heating {
    fn reached(self) -> Idle {
        Idle
    }
}

Here Idle has no reached method, so idle.reached() will not compile. The trade-off is that typestate encodes the state in the type, so you cannot hold a State in a plain variable or send it through a [State; N] queue without erasing it back to an enum. I use typestate for driver initialization sequences where the transitions are known at compile time, and the enum for anything driven by runtime events.

Both patterns are fully no_std-friendly: they are just enums and structs with no allocation, so the same code runs on a microcontroller as on the host test bench.

Sources